forge-openclaw-plugin 0.2.73 → 0.2.80
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/{board-B0TuXl4u.js → board-DKxKOwax.js} +1 -1
- package/dist/assets/index-Chc9CWm5.css +1 -0
- package/dist/assets/{index-clNilMKr.js → index-DEoJdpz5.js} +56 -56
- package/dist/assets/{motion-Dmjq6HPm.js → motion-CM4AfIqo.js} +1 -1
- package/dist/assets/{table-CKKimYN1.js → table-BUeQ9wzR.js} +1 -1
- package/dist/assets/{ui-JBdCP1Qb.js → ui-3Wd4pVaA.js} +1 -1
- package/dist/assets/{vendor-fiXu5f59.js → vendor-BVU0cZC9.js} +243 -240
- package/dist/index.html +7 -7
- package/dist/server/server/src/app.js +69 -19
- package/dist/server/server/src/health.js +254 -9
- package/dist/server/server/src/openapi.js +12 -0
- package/dist/server/server/src/services/devrage.js +20 -6
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/skills/forge-openclaw/SKILL.md +3 -2
- package/skills/forge-openclaw/entity_conversation_playbooks.md +29 -17
- package/dist/assets/index-DtT3Y-Bj.css +0 -1
package/dist/index.html
CHANGED
|
@@ -13,14 +13,14 @@
|
|
|
13
13
|
/>
|
|
14
14
|
<link rel="icon" type="image/png" href="/forge/assets/favicon-BCHm9dUV.ico" />
|
|
15
15
|
<link rel="alternate icon" href="/forge/assets/favicon-BCHm9dUV.ico" />
|
|
16
|
-
<script type="module" crossorigin src="/forge/assets/index-
|
|
17
|
-
<link rel="modulepreload" crossorigin href="/forge/assets/vendor-
|
|
18
|
-
<link rel="modulepreload" crossorigin href="/forge/assets/board-
|
|
19
|
-
<link rel="modulepreload" crossorigin href="/forge/assets/ui-
|
|
20
|
-
<link rel="modulepreload" crossorigin href="/forge/assets/motion-
|
|
21
|
-
<link rel="modulepreload" crossorigin href="/forge/assets/table-
|
|
16
|
+
<script type="module" crossorigin src="/forge/assets/index-DEoJdpz5.js"></script>
|
|
17
|
+
<link rel="modulepreload" crossorigin href="/forge/assets/vendor-BVU0cZC9.js">
|
|
18
|
+
<link rel="modulepreload" crossorigin href="/forge/assets/board-DKxKOwax.js">
|
|
19
|
+
<link rel="modulepreload" crossorigin href="/forge/assets/ui-3Wd4pVaA.js">
|
|
20
|
+
<link rel="modulepreload" crossorigin href="/forge/assets/motion-CM4AfIqo.js">
|
|
21
|
+
<link rel="modulepreload" crossorigin href="/forge/assets/table-BUeQ9wzR.js">
|
|
22
22
|
<link rel="stylesheet" crossorigin href="/forge/assets/vendor-B-Lq_OG3.css">
|
|
23
|
-
<link rel="stylesheet" crossorigin href="/forge/assets/index-
|
|
23
|
+
<link rel="stylesheet" crossorigin href="/forge/assets/index-Chc9CWm5.css">
|
|
24
24
|
</head>
|
|
25
25
|
<body class="bg-canvas text-ink antialiased">
|
|
26
26
|
<div id="root"></div>
|
|
@@ -2895,7 +2895,7 @@ const AGENT_ONBOARDING_ENTITY_CATALOG = [
|
|
|
2895
2895
|
searchHints: [
|
|
2896
2896
|
"Clarify whether the user wants flow discovery, editing, execution, published output, run inspection, or node-level output before choosing the route.",
|
|
2897
2897
|
"Distinguish flow contract, published output, run history, latest-node-output, and chat follow-up questions before reaching for a route.",
|
|
2898
|
-
"If the user is still deciding how to run or edit a flow, read flow detail or the box catalog before asking them for
|
|
2898
|
+
"If the user is still deciding how to run or edit a flow, read flow detail or the box catalog before asking them for structured input details."
|
|
2899
2899
|
],
|
|
2900
2900
|
fieldGuide: []
|
|
2901
2901
|
}),
|
|
@@ -2969,7 +2969,7 @@ const AGENT_ONBOARDING_CONVERSATION_RULES = [
|
|
|
2969
2969
|
"Do not minimize functional analysis, trigger chains, behavior patterns, modes, beliefs, or schema themes. After at least one concrete example is clear, offer one careful interpretive hypothesis when it would help the user understand what the reaction may be protecting, predicting, relieving, or costing.",
|
|
2970
2970
|
"Phrase Psyche interpretive hypotheses as collaborative and testable, not as verdicts. Ask whether the hypothesis lands or needs correction before turning it into a saved belief, pattern, mode, trigger report, or note.",
|
|
2971
2971
|
"Once the Movement, Life Force, or Workbench job is clear, speak in product nouns such as timeline, overlay, weekday template, published output, run detail, or node result instead of generic record language.",
|
|
2972
|
-
"If the next answer would not change the route, wording, timing, or write
|
|
2972
|
+
"If the next answer would not change the route, wording, timing, or write shape in a meaningful way, stop asking and act.",
|
|
2973
2973
|
"Before saving, briefly summarize the working formulation in the user's own language when that would reduce ambiguity.",
|
|
2974
2974
|
"Once the record is clear enough to name, stop exploring broadly and ask only for the last structural detail that still matters.",
|
|
2975
2975
|
"If the record is already clear enough to save, save it instead of performing a ceremonial extra question.",
|
|
@@ -2980,7 +2980,7 @@ const AGENT_ONBOARDING_CONVERSATION_RULES = [
|
|
|
2980
2980
|
"When the user already named a precise correction or review target, do not widen back out into a meta lane question. Confirm only the missing route-selecting detail and then act.",
|
|
2981
2981
|
"Once the route family is clear, say it plainly enough that another agent could follow the same path without guessing.",
|
|
2982
2982
|
"For Movement specifically, treat missing-data corrections as user-defined overlay boxes unless the user is editing an already-recorded stay or trip. When the user already gave a clear instruction like 'that missing block was home', act after only the last ambiguity is resolved.",
|
|
2983
|
-
"For action workflows such as task_run, work_adjustment, questionnaire_run, preference_judgment, preference_signal, and self_observation, keep the question focused on the missing action
|
|
2983
|
+
"For action workflows such as task_run, work_adjustment, questionnaire_run, preference_judgment, preference_signal, and self_observation, keep the question focused on the missing action detail and do not downgrade the request into generic batch CRUD.",
|
|
2984
2984
|
"For read-model-only health surfaces such as sleep_overview and sports_overview, use the dedicated overview reads first when the user wants review, pattern interpretation, recovery context, or training-load context. Move to sleep_session or workout_session writes only after one specific stored session needs enrichment.",
|
|
2985
2985
|
"For normal stored Preferences and questionnaire records, use batch CRUD by default; switch to dedicated action routes only for judgments, signals, run answers, clone/draft/publish lifecycle, or visual comparison gameplay.",
|
|
2986
2986
|
"When the user wants to remember a book, article, paper, source, concept, person, conversation, project reference, recurring explanation, or personal manual, consider wiki_page before note or self_observation.",
|
|
@@ -3344,26 +3344,28 @@ const AGENT_ONBOARDING_ENTITY_CONVERSATION_PLAYBOOKS = [
|
|
|
3344
3344
|
},
|
|
3345
3345
|
{
|
|
3346
3346
|
focus: "questionnaire_instrument",
|
|
3347
|
-
openingQuestion: "What
|
|
3348
|
-
coachingGoal: "Clarify whether the user is authoring a reusable questionnaire and what the instrument
|
|
3347
|
+
openingQuestion: "What honest moment or decision should this questionnaire help someone notice or track?",
|
|
3348
|
+
coachingGoal: "Clarify whether the user is authoring a reusable questionnaire and what honest moment, pattern, or decision the instrument should help someone notice.",
|
|
3349
3349
|
askSequence: [
|
|
3350
|
-
"Ask what the questionnaire
|
|
3350
|
+
"Ask what honest moment, pattern, or decision the questionnaire should help someone notice.",
|
|
3351
3351
|
"Ask who it is for and when it should be used.",
|
|
3352
|
-
"Ask what
|
|
3353
|
-
"Reflect the practical use case back in plain language.",
|
|
3352
|
+
"Ask what the respondent should understand after answering that they might otherwise miss.",
|
|
3353
|
+
"Reflect the practical use case back in plain language before asking for item wording.",
|
|
3354
3354
|
"Ask what would make the instrument distinct instead of redundant if a near-duplicate risk is visible.",
|
|
3355
|
+
"Ask about item shape, response scale, scoring, or provenance only after the purpose and use context are steady.",
|
|
3355
3356
|
"Use batch CRUD for ordinary questionnaire instrument create or update work; use clone, draft, and publish actions only for version lifecycle.",
|
|
3356
3357
|
"Move to draft creation once the purpose is clear."
|
|
3357
3358
|
]
|
|
3358
3359
|
},
|
|
3359
3360
|
{
|
|
3360
3361
|
focus: "questionnaire_run",
|
|
3361
|
-
openingQuestion: "
|
|
3362
|
-
coachingGoal: "Clarify whether the user wants to start, continue, or complete one answer session.",
|
|
3362
|
+
openingQuestion: "What do you want from this questionnaire run right now: start, continue, review, or finish it?",
|
|
3363
|
+
coachingGoal: "Clarify whether the user wants to start, continue, review, or complete one answer session without turning the run into a mechanical form fill.",
|
|
3363
3364
|
askSequence: [
|
|
3364
3365
|
"Ask what the user wants from the run right now: start, continue, review, or finish.",
|
|
3365
3366
|
"Ask which questionnaire or existing run this is about.",
|
|
3366
3367
|
"If the user wants to continue or finish, ask what feels most stuck, unfinished, or important before asking for more content.",
|
|
3368
|
+
"If the user is reviewing answers, ask what the run should help them understand before proposing edits or completion.",
|
|
3367
3369
|
"Use the dedicated questionnaire run start, read, update, and complete routes instead of generic entity CRUD.",
|
|
3368
3370
|
"If answering is still in progress, ask only for the next answer or note that matters."
|
|
3369
3371
|
]
|
|
@@ -3427,7 +3429,7 @@ const AGENT_ONBOARDING_ENTITY_CONVERSATION_PLAYBOOKS = [
|
|
|
3427
3429
|
"If the user wants to send a follow-up into a saved flow chat, confirm the saved flow and what the message should accomplish instead of treating it as a new run or note.",
|
|
3428
3430
|
"If the user already named the flow and action clearly, skip the meta lane question and ask only for the missing run, node, or output scope.",
|
|
3429
3431
|
"If the user wants a stable public input contract or published output, prefer those dedicated reads instead of detouring through run history first.",
|
|
3430
|
-
"If the user is still shaping
|
|
3432
|
+
"If the user is still shaping one-off inputs or an edit, prefer flow detail or box catalog reads before asking for structured inputs.",
|
|
3431
3433
|
"If the user is debugging one failed run, ask whether the useful artifact is the run summary, one node result, the latest node output, or the published output before you start asking for edits.",
|
|
3432
3434
|
"Prefer flow detail or published-output reads for stable contracts, and use run or node-result routes only when the user is asking about execution history or debugging.",
|
|
3433
3435
|
"Route to the dedicated workbench route family once the execution lane is clear."
|
|
@@ -4474,7 +4476,7 @@ function buildAgentOnboardingPayload(request) {
|
|
|
4474
4476
|
taskTimebox: "A planned or live calendar slot tied to a task. Timeboxes can be suggested in advance or created automatically from active task runs.",
|
|
4475
4477
|
workAdjustment: "A work adjustment is a truthful signed minute correction on an existing task or project when real work happened but no live run was active.",
|
|
4476
4478
|
movement: "Forge Movement is the first-class mobility surface. It is a timeline of stays and trips: stays capture time spent in the same place, and trips capture travel between places. Use it for time-in-place questions, travel-history review, specific stay or trip edits, selected-span aggregates, known places, and links to other Forge records rather than pretending stays and trips are normal batch CRUD entities.",
|
|
4477
|
-
lifeForce: "Life Force is Forge's energy-budget and fatigue model. Read it through the dedicated life-force
|
|
4479
|
+
lifeForce: "Life Force is Forge's energy-budget and fatigue model. Read it through the dedicated life-force state and update it through focused profile, weekday-template, and fatigue-signal routes rather than generic entity CRUD.",
|
|
4478
4480
|
workbench: "Workbench is Forge's graph-flow execution system. Treat flows, runs, published outputs, node results, and latest-node-output reads as a dedicated API family instead of a normal entity-batch surface.",
|
|
4479
4481
|
psyche: "Forge Psyche is the reflective domain for values, patterns, behaviors, beliefs, modes, flashcards, and trigger reports. It is sensitive and should be handled deliberately."
|
|
4480
4482
|
},
|
|
@@ -4491,7 +4493,10 @@ function buildAgentOnboardingPayload(request) {
|
|
|
4491
4493
|
emotionDefinition: "An emotion definition is reusable emotion vocabulary for trigger reports. Reports can either reference one or fall back to raw labels.",
|
|
4492
4494
|
triggerReport: "A trigger report is the one-episode incident chain: situation, emotions, thoughts, behaviors, consequences, extra mode labels, schema themes, and next moves."
|
|
4493
4495
|
},
|
|
4494
|
-
psycheCoachingPlaybooks: AGENT_ONBOARDING_PSYCHE_PLAYBOOKS.map(enrichConversationPlaybookWithRouteInfo
|
|
4496
|
+
psycheCoachingPlaybooks: AGENT_ONBOARDING_PSYCHE_PLAYBOOKS.map((playbook) => enrichConversationPlaybookWithRouteInfo({
|
|
4497
|
+
...playbook,
|
|
4498
|
+
openingQuestion: playbook.exampleQuestions[0] ?? playbook.askSequence[0] ?? ""
|
|
4499
|
+
})),
|
|
4495
4500
|
conversationRules: AGENT_ONBOARDING_CONVERSATION_RULES,
|
|
4496
4501
|
entityConversationPlaybooks: AGENT_ONBOARDING_ENTITY_CONVERSATION_PLAYBOOKS.map(enrichConversationPlaybookWithRouteInfo),
|
|
4497
4502
|
relationshipModel: [
|
|
@@ -4637,6 +4642,31 @@ function buildAgentOnboardingPayload(request) {
|
|
|
4637
4642
|
classification: "specialized_domain_surface",
|
|
4638
4643
|
aliases: ["movement", "Movement"],
|
|
4639
4644
|
summary: "Dedicated movement workspace API. Use these routes for stays, trips, time-in-place questions, visited places, trip detail, selection aggregates, user-defined overlays, and repair actions on already-recorded movement data.",
|
|
4645
|
+
routeKeys: [
|
|
4646
|
+
"day",
|
|
4647
|
+
"month",
|
|
4648
|
+
"allTime",
|
|
4649
|
+
"timeline",
|
|
4650
|
+
"places",
|
|
4651
|
+
"boxDetail",
|
|
4652
|
+
"tripDetail",
|
|
4653
|
+
"selection",
|
|
4654
|
+
"settings",
|
|
4655
|
+
"settingsUpdate",
|
|
4656
|
+
"placeCreate",
|
|
4657
|
+
"placeUpdate",
|
|
4658
|
+
"userBoxCreate",
|
|
4659
|
+
"userBoxPreflight",
|
|
4660
|
+
"userBoxUpdate",
|
|
4661
|
+
"userBoxDelete",
|
|
4662
|
+
"automaticBoxInvalidate",
|
|
4663
|
+
"stayUpdate",
|
|
4664
|
+
"stayDelete",
|
|
4665
|
+
"tripUpdate",
|
|
4666
|
+
"tripDelete",
|
|
4667
|
+
"tripPointUpdate",
|
|
4668
|
+
"tripPointDelete"
|
|
4669
|
+
],
|
|
4640
4670
|
routeSelectionQuestions: [
|
|
4641
4671
|
"Is the user asking for a day, month, all-time, timeline, place, trip detail, selected-span, or settings answer?",
|
|
4642
4672
|
"Is this a missing-gap overlay, a saved-overlay repair, or an edit to one already-recorded stay, trip, or trip point?",
|
|
@@ -4709,6 +4739,7 @@ function buildAgentOnboardingPayload(request) {
|
|
|
4709
4739
|
classification: "specialized_domain_surface",
|
|
4710
4740
|
aliases: ["life_force", "life-force", "Life Force"],
|
|
4711
4741
|
summary: "Dedicated life-force API. Use it to read the current energy budget, drains, recommendations, and warnings, then patch only the parts that are meant to be user-controlled.",
|
|
4742
|
+
routeKeys: ["overview", "profile", "weekdayTemplate", "fatigueSignal"],
|
|
4712
4743
|
routeSelectionQuestions: [
|
|
4713
4744
|
"Is the user trying to understand the overview, change durable profile assumptions, change a weekday curve, or log a right-now fatigue signal?",
|
|
4714
4745
|
"Are they describing a repeatable weekly shape or a one-off current state?",
|
|
@@ -4742,6 +4773,7 @@ function buildAgentOnboardingPayload(request) {
|
|
|
4742
4773
|
classification: "specialized_domain_surface",
|
|
4743
4774
|
aliases: ["lifeForce", "life-force", "Life Force"],
|
|
4744
4775
|
summary: "Alias for the dedicated Life Force API keyed to the entity-style name `life_force`. Use the same overview, profile, weekday-template, and fatigue-signal routes as `lifeForce`.",
|
|
4776
|
+
routeKeys: ["overview", "profile", "weekdayTemplate", "fatigueSignal"],
|
|
4745
4777
|
routeSelectionQuestions: [
|
|
4746
4778
|
"Is the user trying to understand the overview, change durable profile assumptions, change a weekday curve, or log a right-now fatigue signal?",
|
|
4747
4779
|
"Are they describing a repeatable weekly shape or a one-off current state?",
|
|
@@ -4776,6 +4808,24 @@ function buildAgentOnboardingPayload(request) {
|
|
|
4776
4808
|
classification: "specialized_domain_surface",
|
|
4777
4809
|
aliases: ["workbench", "Workbench"],
|
|
4778
4810
|
summary: "Dedicated graph-flow API. Use it for flow catalog reads, flow CRUD, execution, run history, published outputs, node results, and latest successful node outputs.",
|
|
4811
|
+
routeKeys: [
|
|
4812
|
+
"listFlows",
|
|
4813
|
+
"flowById",
|
|
4814
|
+
"flowBySlug",
|
|
4815
|
+
"publishedOutput",
|
|
4816
|
+
"runs",
|
|
4817
|
+
"runDetail",
|
|
4818
|
+
"runNodes",
|
|
4819
|
+
"nodeResult",
|
|
4820
|
+
"latestNodeOutput",
|
|
4821
|
+
"boxCatalog",
|
|
4822
|
+
"createFlow",
|
|
4823
|
+
"updateFlow",
|
|
4824
|
+
"deleteFlow",
|
|
4825
|
+
"runFlow",
|
|
4826
|
+
"runByPayload",
|
|
4827
|
+
"chatFlow"
|
|
4828
|
+
],
|
|
4779
4829
|
routeSelectionQuestions: [
|
|
4780
4830
|
"Is the job flow discovery, flow creation, flow editing, flow deletion, execution, run history, published output, run detail, node result, latest node output, or flow chat follow-up?",
|
|
4781
4831
|
"Does the user need a stable public contract or one execution artifact?",
|
|
@@ -4825,8 +4875,8 @@ function buildAgentOnboardingPayload(request) {
|
|
|
4825
4875
|
"Workbench is a dedicated execution surface, not a batch CRUD entity family.",
|
|
4826
4876
|
"Route-selection questions are internal. User-facing questions should ask whether the user needs the saved flow, its input contract, one run, one node, or the public result instead of reciting Workbench route keys.",
|
|
4827
4877
|
"Use the flow routes when the agent needs stable public input contracts, published outputs, node-level results, or reusable execution history.",
|
|
4828
|
-
"If the user is still figuring out inputs or editable structure, read flow detail or box catalog before asking them to
|
|
4829
|
-
"For flow creation, clarify what the flow should reliably produce, which input contract it should accept, and which first node or box anchors the flow before asking for structured
|
|
4878
|
+
"If the user is still figuring out inputs or editable structure, read flow detail or box catalog before asking them to reconstruct structured inputs from memory.",
|
|
4879
|
+
"For flow creation, clarify what the flow should reliably produce, which input contract it should accept, and which first node or box anchors the flow before asking for structured input details.",
|
|
4830
4880
|
"For flow edits, ask what behavior should change while preserving the public contract unless the user explicitly wants the contract changed.",
|
|
4831
4881
|
"For flow deletion, confirm the saved flow and whether published outputs or run history need preservation elsewhere before using the delete route.",
|
|
4832
4882
|
"For saved flow chat follow-ups, use POST /api/v1/workbench/flows/:id/chat only when the user wants to continue a flow-specific conversation. Do not turn that into a new run, note, or generic entity update unless the user asks.",
|
|
@@ -4891,8 +4941,8 @@ function buildAgentOnboardingPayload(request) {
|
|
|
4891
4941
|
],
|
|
4892
4942
|
verifyCommands: [
|
|
4893
4943
|
`curl -s ${origin}/api/v1/health`,
|
|
4894
|
-
"openclaw plugins install ./projects/forge/openclaw-plugin",
|
|
4895
|
-
"openclaw plugins
|
|
4944
|
+
"openclaw plugins install --link --dangerously-force-unsafe-install ./projects/forge/openclaw-plugin",
|
|
4945
|
+
"openclaw plugins inspect forge-openclaw-plugin --runtime",
|
|
4896
4946
|
"openclaw gateway restart",
|
|
4897
4947
|
"openclaw forge onboarding",
|
|
4898
4948
|
"openclaw forge health"
|
|
@@ -4900,7 +4950,7 @@ function buildAgentOnboardingPayload(request) {
|
|
|
4900
4950
|
configNotes: [
|
|
4901
4951
|
"Localhost and Tailscale targets can usually use the operator-session path without a long-lived token.",
|
|
4902
4952
|
"The operator-session route is /api/v1/auth/operator-session, so trusted local OpenClaw onboarding does not need a browser confirmation step.",
|
|
4903
|
-
"If your current OpenClaw build blocks the repo-local install because of the package scanner, keep the repo folder on plugins.load.paths and verify that plugins
|
|
4953
|
+
"If your current OpenClaw build blocks the repo-local install because of the package scanner, keep the repo folder on plugins.load.paths and verify that plugins inspect --runtime still points at the local Forge source path before continuing.",
|
|
4904
4954
|
"Use a distinct actor label such as Albert (claw) so OpenClaw-originated work stays obvious in Forge provenance.",
|
|
4905
4955
|
"Create each agent as a Forge bot user, then use userId or userIds in tool inputs whenever the agent should focus on one human, one bot, or a specific collaboration slice.",
|
|
4906
4956
|
"If you genuinely need a durable managed token, create it through /api/v1/settings/tokens instead of sending the operator into the Settings UI."
|
|
@@ -7620,7 +7670,7 @@ export async function buildServer(options = {}) {
|
|
|
7620
7670
|
app.post("/api/v1/mobile/healthkit/sync-sessions", async (request) => ({
|
|
7621
7671
|
upload: startMobileHealthSyncSession(mobileHealthSyncSessionStartSchema.parse(request.body ?? {}))
|
|
7622
7672
|
}));
|
|
7623
|
-
app.post("/api/v1/mobile/healthkit/sync-sessions/:id/chunks", { bodyLimit:
|
|
7673
|
+
app.post("/api/v1/mobile/healthkit/sync-sessions/:id/chunks", { bodyLimit: 40_000_000 }, async (request) => {
|
|
7624
7674
|
const { id } = request.params;
|
|
7625
7675
|
const rawPayloadJson = JSON.stringify((request.body ?? {}).payload ?? {});
|
|
7626
7676
|
return {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { inflateSync } from "node:zlib";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import { getDatabase, runInTransaction } from "./db.js";
|
|
4
5
|
import { HttpError } from "./errors.js";
|
|
@@ -260,11 +261,13 @@ export const mobileHealthSyncSchema = z.object({
|
|
|
260
261
|
screenTime: screenTimeSyncPayloadSchema.default({})
|
|
261
262
|
});
|
|
262
263
|
const HEALTH_MOBILE_SYNC_SCHEMA_VERSION = "healthkit-sync-v2";
|
|
263
|
-
const HEALTH_MOBILE_SYNC_CHUNK_TARGET_BYTES =
|
|
264
|
-
const HEALTH_MOBILE_SYNC_CHUNK_MAX_BYTES =
|
|
264
|
+
const HEALTH_MOBILE_SYNC_CHUNK_TARGET_BYTES = 12_000_000;
|
|
265
|
+
const HEALTH_MOBILE_SYNC_CHUNK_MAX_BYTES = 24_000_000;
|
|
265
266
|
const HEALTH_MOBILE_SYNC_CHUNK_PAYLOAD_ENCODING = "payload_json_base64";
|
|
267
|
+
const HEALTH_MOBILE_SYNC_COMPRESSED_CHUNK_PAYLOAD_ENCODING = "payload_json_deflate_base64";
|
|
266
268
|
const HEALTH_MOBILE_SYNC_ACCEPTED_CHUNK_PAYLOAD_ENCODINGS = [
|
|
267
269
|
HEALTH_MOBILE_SYNC_CHUNK_PAYLOAD_ENCODING,
|
|
270
|
+
HEALTH_MOBILE_SYNC_COMPRESSED_CHUNK_PAYLOAD_ENCODING,
|
|
268
271
|
"legacy_payload_object"
|
|
269
272
|
];
|
|
270
273
|
const HEALTH_MOBILE_SYNC_SESSION_TTL_MS = 1000 * 60 * 60 * 24;
|
|
@@ -343,7 +346,9 @@ export const mobileHealthSyncChunkSchema = z.object({
|
|
|
343
346
|
family: mobileHealthSyncFamilySchema,
|
|
344
347
|
recordCount: z.number().int().nonnegative().default(0),
|
|
345
348
|
byteCount: z.number().int().nonnegative().default(0),
|
|
349
|
+
compressedByteCount: z.number().int().nonnegative().optional(),
|
|
346
350
|
checksumSha256: z.string().trim().min(1),
|
|
351
|
+
payloadJsonDeflateBase64: z.string().trim().min(1).optional(),
|
|
347
352
|
payloadJsonBase64: z.string().trim().min(1).optional(),
|
|
348
353
|
payload: mobileHealthSyncChunkPayloadSchema.default({})
|
|
349
354
|
});
|
|
@@ -2691,6 +2696,13 @@ function chunkPayloadJson(payload) {
|
|
|
2691
2696
|
function chunkPayloadChecksum(payloadJson) {
|
|
2692
2697
|
return createHash("sha256").update(payloadJson).digest("hex");
|
|
2693
2698
|
}
|
|
2699
|
+
function normalizedChunkChecksum(checksum) {
|
|
2700
|
+
const normalized = checksum.trim().toLowerCase();
|
|
2701
|
+
if (!/^[a-f0-9]{64}$/.test(normalized)) {
|
|
2702
|
+
throw new HttpError(400, "invalid_chunk_checksum", "The HealthKit sync chunk checksum is invalid.");
|
|
2703
|
+
}
|
|
2704
|
+
return normalized;
|
|
2705
|
+
}
|
|
2694
2706
|
function parseBase64ChunkPayload(payloadJsonBase64, context) {
|
|
2695
2707
|
const compactBase64 = payloadJsonBase64.replace(/\s/g, "");
|
|
2696
2708
|
if (compactBase64.length === 0 ||
|
|
@@ -2724,10 +2736,71 @@ function parseBase64ChunkPayload(payloadJsonBase64, context) {
|
|
|
2724
2736
|
payload: parsedPayload,
|
|
2725
2737
|
payloadJson,
|
|
2726
2738
|
byteCount: decoded.length,
|
|
2739
|
+
compressedByteCount: undefined,
|
|
2727
2740
|
mode: "payload_json_base64"
|
|
2728
2741
|
};
|
|
2729
2742
|
}
|
|
2743
|
+
function parseDeflateBase64ChunkPayload(payloadJsonDeflateBase64, context) {
|
|
2744
|
+
const compactBase64 = payloadJsonDeflateBase64.replace(/\s/g, "");
|
|
2745
|
+
if (compactBase64.length === 0 ||
|
|
2746
|
+
compactBase64.length % 4 === 1 ||
|
|
2747
|
+
!/^[A-Za-z0-9+/]*={0,2}$/.test(compactBase64)) {
|
|
2748
|
+
throw new HttpError(400, "invalid_chunk_payload", "The HealthKit sync compressed payload encoding is invalid.", { mode: HEALTH_MOBILE_SYNC_COMPRESSED_CHUNK_PAYLOAD_ENCODING });
|
|
2749
|
+
}
|
|
2750
|
+
const compressed = Buffer.from(compactBase64, "base64");
|
|
2751
|
+
const recoded = compressed.toString("base64").replace(/=+$/g, "");
|
|
2752
|
+
if (recoded !== compactBase64.replace(/=+$/g, "")) {
|
|
2753
|
+
throw new HttpError(400, "invalid_chunk_payload", "The HealthKit sync compressed payload encoding is invalid.", { mode: HEALTH_MOBILE_SYNC_COMPRESSED_CHUNK_PAYLOAD_ENCODING });
|
|
2754
|
+
}
|
|
2755
|
+
let decoded;
|
|
2756
|
+
try {
|
|
2757
|
+
decoded = inflateSync(compressed);
|
|
2758
|
+
}
|
|
2759
|
+
catch (error) {
|
|
2760
|
+
console.warn("[healthkit-sync] invalid compressed chunk payload", {
|
|
2761
|
+
syncSessionId: context.syncSessionId,
|
|
2762
|
+
chunkId: context.chunkId,
|
|
2763
|
+
family: context.family,
|
|
2764
|
+
mode: HEALTH_MOBILE_SYNC_COMPRESSED_CHUNK_PAYLOAD_ENCODING,
|
|
2765
|
+
compressedBytes: compressed.length,
|
|
2766
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2767
|
+
});
|
|
2768
|
+
throw new HttpError(400, "invalid_chunk_payload", "The HealthKit sync compressed payload cannot be decompressed.", { mode: HEALTH_MOBILE_SYNC_COMPRESSED_CHUNK_PAYLOAD_ENCODING });
|
|
2769
|
+
}
|
|
2770
|
+
const payloadJson = decoded.toString("utf8");
|
|
2771
|
+
let rawPayload;
|
|
2772
|
+
try {
|
|
2773
|
+
rawPayload = JSON.parse(payloadJson);
|
|
2774
|
+
}
|
|
2775
|
+
catch (error) {
|
|
2776
|
+
console.warn("[healthkit-sync] invalid compressed chunk JSON payload", {
|
|
2777
|
+
syncSessionId: context.syncSessionId,
|
|
2778
|
+
chunkId: context.chunkId,
|
|
2779
|
+
family: context.family,
|
|
2780
|
+
mode: HEALTH_MOBILE_SYNC_COMPRESSED_CHUNK_PAYLOAD_ENCODING,
|
|
2781
|
+
bytes: decoded.length,
|
|
2782
|
+
compressedBytes: compressed.length,
|
|
2783
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2784
|
+
});
|
|
2785
|
+
throw new HttpError(400, "invalid_chunk_payload", "The HealthKit sync compressed payload is not valid JSON.", { mode: HEALTH_MOBILE_SYNC_COMPRESSED_CHUNK_PAYLOAD_ENCODING });
|
|
2786
|
+
}
|
|
2787
|
+
const parsedPayload = mobileHealthSyncChunkPayloadSchema.parse(rawPayload);
|
|
2788
|
+
return {
|
|
2789
|
+
payload: parsedPayload,
|
|
2790
|
+
payloadJson,
|
|
2791
|
+
byteCount: decoded.length,
|
|
2792
|
+
compressedByteCount: compressed.length,
|
|
2793
|
+
mode: HEALTH_MOBILE_SYNC_COMPRESSED_CHUNK_PAYLOAD_ENCODING
|
|
2794
|
+
};
|
|
2795
|
+
}
|
|
2730
2796
|
function resolveChunkWirePayload(parsed, syncSessionId, rawPayloadJson) {
|
|
2797
|
+
if (parsed.payloadJsonDeflateBase64) {
|
|
2798
|
+
return parseDeflateBase64ChunkPayload(parsed.payloadJsonDeflateBase64, {
|
|
2799
|
+
syncSessionId,
|
|
2800
|
+
chunkId: parsed.chunkId,
|
|
2801
|
+
family: parsed.family
|
|
2802
|
+
});
|
|
2803
|
+
}
|
|
2731
2804
|
if (parsed.payloadJsonBase64) {
|
|
2732
2805
|
return parseBase64ChunkPayload(parsed.payloadJsonBase64, {
|
|
2733
2806
|
syncSessionId,
|
|
@@ -2740,6 +2813,7 @@ function resolveChunkWirePayload(parsed, syncSessionId, rawPayloadJson) {
|
|
|
2740
2813
|
payload: parsed.payload,
|
|
2741
2814
|
payloadJson,
|
|
2742
2815
|
byteCount: Buffer.byteLength(payloadJson, "utf8"),
|
|
2816
|
+
compressedByteCount: undefined,
|
|
2743
2817
|
mode: "legacy_payload_object"
|
|
2744
2818
|
};
|
|
2745
2819
|
}
|
|
@@ -2779,6 +2853,76 @@ function summarizeChunkPayload(family, payload) {
|
|
|
2779
2853
|
};
|
|
2780
2854
|
}
|
|
2781
2855
|
}
|
|
2856
|
+
function mobileSyncSessionPairing(session) {
|
|
2857
|
+
const pairing = getDatabase()
|
|
2858
|
+
.prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
|
|
2859
|
+
.get(session.pairing_session_id);
|
|
2860
|
+
if (!pairing) {
|
|
2861
|
+
throw new HttpError(404, "pairing_invalid", "The sync pairing no longer exists.");
|
|
2862
|
+
}
|
|
2863
|
+
return pairing;
|
|
2864
|
+
}
|
|
2865
|
+
function mobileSyncSessionMetadata(session) {
|
|
2866
|
+
return safeJsonParse(session.source_metadata_json, {});
|
|
2867
|
+
}
|
|
2868
|
+
function applyWorkoutSummaryChunkImmediately(session, workouts) {
|
|
2869
|
+
if (workouts.length === 0) {
|
|
2870
|
+
return;
|
|
2871
|
+
}
|
|
2872
|
+
const pairing = mobileSyncSessionPairing(session);
|
|
2873
|
+
const metadata = mobileSyncSessionMetadata(session);
|
|
2874
|
+
const device = metadata.device ?? {
|
|
2875
|
+
name: pairing.device_name ?? "iPhone",
|
|
2876
|
+
platform: pairing.platform ?? "ios",
|
|
2877
|
+
appVersion: pairing.app_version ?? "",
|
|
2878
|
+
sourceDevice: pairing.device_name ?? "iPhone"
|
|
2879
|
+
};
|
|
2880
|
+
runInTransaction(() => {
|
|
2881
|
+
const now = nowIso();
|
|
2882
|
+
const runId = `hir_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
2883
|
+
let createdCount = 0;
|
|
2884
|
+
let updatedCount = 0;
|
|
2885
|
+
let mergedCount = 0;
|
|
2886
|
+
getDatabase()
|
|
2887
|
+
.prepare(`INSERT INTO health_import_runs (
|
|
2888
|
+
id, pairing_session_id, user_id, source, source_device, status, payload_summary_json,
|
|
2889
|
+
imported_count, created_count, updated_count, merged_count, imported_at, created_at, updated_at
|
|
2890
|
+
)
|
|
2891
|
+
VALUES (?, ?, ?, 'ios_companion', ?, 'running', '{}', 0, 0, 0, 0, ?, ?, ?)`)
|
|
2892
|
+
.run(runId, pairing.id, pairing.user_id, device.sourceDevice, now, now, now);
|
|
2893
|
+
for (const workout of workouts) {
|
|
2894
|
+
const result = insertOrUpdateWorkoutSession(pairing, workout);
|
|
2895
|
+
if (result.mode === "created") {
|
|
2896
|
+
createdCount += 1;
|
|
2897
|
+
}
|
|
2898
|
+
else if (result.mode === "merged") {
|
|
2899
|
+
mergedCount += 1;
|
|
2900
|
+
}
|
|
2901
|
+
else {
|
|
2902
|
+
updatedCount += 1;
|
|
2903
|
+
}
|
|
2904
|
+
summarizeUserHealthDay(pairing.user_id, dayKey(workout.startedAt));
|
|
2905
|
+
}
|
|
2906
|
+
getDatabase()
|
|
2907
|
+
.prepare(`UPDATE companion_pairing_sessions
|
|
2908
|
+
SET status = 'healthy', device_name = ?, platform = ?, app_version = ?,
|
|
2909
|
+
last_seen_at = ?, last_sync_at = ?, last_sync_error = NULL,
|
|
2910
|
+
paired_at = COALESCE(paired_at, ?), updated_at = ?
|
|
2911
|
+
WHERE id = ?`)
|
|
2912
|
+
.run(device.name, device.platform, device.appVersion, now, now, now, now, pairing.id);
|
|
2913
|
+
getDatabase()
|
|
2914
|
+
.prepare(`UPDATE health_import_runs
|
|
2915
|
+
SET source_device = ?, status = 'completed', payload_summary_json = ?,
|
|
2916
|
+
imported_count = ?, created_count = ?, updated_count = ?, merged_count = ?,
|
|
2917
|
+
imported_at = ?, updated_at = ?
|
|
2918
|
+
WHERE id = ?`)
|
|
2919
|
+
.run(device.sourceDevice, JSON.stringify({
|
|
2920
|
+
progressiveChunk: true,
|
|
2921
|
+
syncSessionId: session.id,
|
|
2922
|
+
workouts: workouts.length
|
|
2923
|
+
}), workouts.length, createdCount, updatedCount, mergedCount, now, now, runId);
|
|
2924
|
+
});
|
|
2925
|
+
}
|
|
2782
2926
|
function updateMobileSyncSessionProgress(syncSessionId) {
|
|
2783
2927
|
const chunks = getDatabase()
|
|
2784
2928
|
.prepare(`SELECT family, record_count, byte_count
|
|
@@ -2837,7 +2981,7 @@ export function startMobileHealthSyncSession(payload) {
|
|
|
2837
2981
|
chunkMaxBytes: HEALTH_MOBILE_SYNC_CHUNK_MAX_BYTES,
|
|
2838
2982
|
chunkPayloadEncoding: HEALTH_MOBILE_SYNC_CHUNK_PAYLOAD_ENCODING,
|
|
2839
2983
|
acceptedPayloadEncodings: HEALTH_MOBILE_SYNC_ACCEPTED_CHUNK_PAYLOAD_ENCODINGS,
|
|
2840
|
-
supportsCompression:
|
|
2984
|
+
supportsCompression: true,
|
|
2841
2985
|
acceptedFamilies: safeJsonParse(existing.requested_families_json, parsed.requestedFamilies),
|
|
2842
2986
|
receivedChunkIds
|
|
2843
2987
|
};
|
|
@@ -2866,13 +3010,14 @@ export function startMobileHealthSyncSession(payload) {
|
|
|
2866
3010
|
chunkMaxBytes: HEALTH_MOBILE_SYNC_CHUNK_MAX_BYTES,
|
|
2867
3011
|
chunkPayloadEncoding: HEALTH_MOBILE_SYNC_CHUNK_PAYLOAD_ENCODING,
|
|
2868
3012
|
acceptedPayloadEncodings: HEALTH_MOBILE_SYNC_ACCEPTED_CHUNK_PAYLOAD_ENCODINGS,
|
|
2869
|
-
supportsCompression:
|
|
3013
|
+
supportsCompression: true,
|
|
2870
3014
|
acceptedFamilies: parsed.requestedFamilies,
|
|
2871
3015
|
receivedChunkIds: []
|
|
2872
3016
|
};
|
|
2873
3017
|
}
|
|
2874
3018
|
export function ingestMobileHealthSyncChunk(syncSessionId, payload, rawPayloadJson) {
|
|
2875
3019
|
const parsed = mobileHealthSyncChunkSchema.parse(payload);
|
|
3020
|
+
const clientChecksum = normalizedChunkChecksum(parsed.checksumSha256);
|
|
2876
3021
|
const session = ensureRunningMobileSyncSession(syncSessionId);
|
|
2877
3022
|
const requestedFamilies = safeJsonParse(session.requested_families_json, []);
|
|
2878
3023
|
if (requestedFamilies.length > 0 &&
|
|
@@ -2884,7 +3029,7 @@ export function ingestMobileHealthSyncChunk(syncSessionId, payload, rawPayloadJs
|
|
|
2884
3029
|
WHERE sync_session_id = ? AND chunk_id = ?`)
|
|
2885
3030
|
.get(syncSessionId, parsed.chunkId);
|
|
2886
3031
|
if (existing) {
|
|
2887
|
-
if (existing.checksum_sha256 !==
|
|
3032
|
+
if (existing.checksum_sha256 !== clientChecksum) {
|
|
2888
3033
|
throw new HttpError(409, "chunk_checksum_mismatch", "A chunk with the same id was already accepted with different content.");
|
|
2889
3034
|
}
|
|
2890
3035
|
const progress = updateMobileSyncSessionProgress(syncSessionId);
|
|
@@ -2905,14 +3050,52 @@ export function ingestMobileHealthSyncChunk(syncSessionId, payload, rawPayloadJs
|
|
|
2905
3050
|
actualBytes: actualByteCount
|
|
2906
3051
|
});
|
|
2907
3052
|
}
|
|
3053
|
+
if (wirePayload.mode !== "legacy_payload_object" && parsed.byteCount !== actualByteCount) {
|
|
3054
|
+
console.warn("[healthkit-sync] chunk byte count mismatch", {
|
|
3055
|
+
syncSessionId,
|
|
3056
|
+
chunkId: parsed.chunkId,
|
|
3057
|
+
family: parsed.family,
|
|
3058
|
+
mode: wirePayload.mode,
|
|
3059
|
+
clientByteCount: parsed.byteCount,
|
|
3060
|
+
actualByteCount
|
|
3061
|
+
});
|
|
3062
|
+
throw new HttpError(409, "chunk_byte_count_mismatch", "The HealthKit sync chunk byte count does not match its decoded payload.", {
|
|
3063
|
+
syncSessionId,
|
|
3064
|
+
chunkId: parsed.chunkId,
|
|
3065
|
+
family: parsed.family,
|
|
3066
|
+
actualBytes: actualByteCount,
|
|
3067
|
+
clientBytes: parsed.byteCount,
|
|
3068
|
+
mode: wirePayload.mode
|
|
3069
|
+
});
|
|
3070
|
+
}
|
|
3071
|
+
if (wirePayload.compressedByteCount !== undefined &&
|
|
3072
|
+
parsed.compressedByteCount !== undefined &&
|
|
3073
|
+
parsed.compressedByteCount !== wirePayload.compressedByteCount) {
|
|
3074
|
+
console.warn("[healthkit-sync] chunk compressed byte count mismatch", {
|
|
3075
|
+
syncSessionId,
|
|
3076
|
+
chunkId: parsed.chunkId,
|
|
3077
|
+
family: parsed.family,
|
|
3078
|
+
mode: wirePayload.mode,
|
|
3079
|
+
clientCompressedByteCount: parsed.compressedByteCount,
|
|
3080
|
+
actualCompressedByteCount: wirePayload.compressedByteCount
|
|
3081
|
+
});
|
|
3082
|
+
throw new HttpError(409, "chunk_compressed_byte_count_mismatch", "The HealthKit sync chunk compressed byte count does not match its decoded payload.", {
|
|
3083
|
+
syncSessionId,
|
|
3084
|
+
chunkId: parsed.chunkId,
|
|
3085
|
+
family: parsed.family,
|
|
3086
|
+
actualCompressedBytes: wirePayload.compressedByteCount,
|
|
3087
|
+
clientCompressedBytes: parsed.compressedByteCount,
|
|
3088
|
+
mode: wirePayload.mode
|
|
3089
|
+
});
|
|
3090
|
+
}
|
|
2908
3091
|
const serverChecksum = chunkPayloadChecksum(payloadJson);
|
|
2909
|
-
if (
|
|
3092
|
+
if (clientChecksum !== serverChecksum) {
|
|
2910
3093
|
console.warn("[healthkit-sync] chunk checksum mismatch", {
|
|
2911
3094
|
syncSessionId,
|
|
2912
3095
|
chunkId: parsed.chunkId,
|
|
2913
3096
|
family: parsed.family,
|
|
2914
3097
|
mode: wirePayload.mode,
|
|
2915
|
-
clientChecksum:
|
|
3098
|
+
clientChecksum: clientChecksum.slice(0, 12),
|
|
2916
3099
|
serverChecksum: serverChecksum.slice(0, 12),
|
|
2917
3100
|
clientByteCount: parsed.byteCount,
|
|
2918
3101
|
actualByteCount
|
|
@@ -2923,7 +3106,7 @@ export function ingestMobileHealthSyncChunk(syncSessionId, payload, rawPayloadJs
|
|
|
2923
3106
|
family: parsed.family,
|
|
2924
3107
|
actualBytes: actualByteCount,
|
|
2925
3108
|
clientBytes: parsed.byteCount,
|
|
2926
|
-
clientChecksumPrefix:
|
|
3109
|
+
clientChecksumPrefix: clientChecksum.slice(0, 12),
|
|
2927
3110
|
serverChecksumPrefix: serverChecksum.slice(0, 12),
|
|
2928
3111
|
mode: wirePayload.mode
|
|
2929
3112
|
});
|
|
@@ -2936,13 +3119,17 @@ export function ingestMobileHealthSyncChunk(syncSessionId, payload, rawPayloadJs
|
|
|
2936
3119
|
received_at, applied_at, created_at, updated_at
|
|
2937
3120
|
)
|
|
2938
3121
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
2939
|
-
.run(mobileSyncChunkRecordId(), syncSessionId, parsed.chunkId, parsed.sequence, parsed.family, serverChecksum, parsed.recordCount,
|
|
3122
|
+
.run(mobileSyncChunkRecordId(), syncSessionId, parsed.chunkId, parsed.sequence, parsed.family, serverChecksum, parsed.recordCount, actualByteCount, payloadJson, JSON.stringify({
|
|
2940
3123
|
...summarizeChunkPayload(parsed.family, wirePayload.payload),
|
|
2941
3124
|
clientByteCount: parsed.byteCount,
|
|
2942
3125
|
actualByteCount,
|
|
3126
|
+
compressedByteCount: wirePayload.compressedByteCount ?? null,
|
|
2943
3127
|
serverChecksum,
|
|
2944
3128
|
mode: wirePayload.mode
|
|
2945
3129
|
}), now, now, now, now);
|
|
3130
|
+
if (parsed.family === "workout_summaries") {
|
|
3131
|
+
applyWorkoutSummaryChunkImmediately(session, wirePayload.payload.workouts ?? []);
|
|
3132
|
+
}
|
|
2946
3133
|
const progress = updateMobileSyncSessionProgress(syncSessionId);
|
|
2947
3134
|
return {
|
|
2948
3135
|
accepted: true,
|
|
@@ -3569,8 +3756,64 @@ export function getSleepViewData(userIds) {
|
|
|
3569
3756
|
sessions: recentDisplay
|
|
3570
3757
|
};
|
|
3571
3758
|
}
|
|
3759
|
+
function pickVitalMetricValue(metrics, candidates) {
|
|
3760
|
+
for (const key of candidates) {
|
|
3761
|
+
const metric = metrics[key];
|
|
3762
|
+
if (!metric) {
|
|
3763
|
+
continue;
|
|
3764
|
+
}
|
|
3765
|
+
const value = vitalMetricPrimaryValue({
|
|
3766
|
+
aggregation: metric.aggregation,
|
|
3767
|
+
latest: metric.latest ?? null,
|
|
3768
|
+
average: metric.average ?? null,
|
|
3769
|
+
total: metric.total ?? null,
|
|
3770
|
+
maximum: metric.maximum ?? null
|
|
3771
|
+
});
|
|
3772
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
3773
|
+
return value;
|
|
3774
|
+
}
|
|
3775
|
+
}
|
|
3776
|
+
return null;
|
|
3777
|
+
}
|
|
3778
|
+
function buildFitnessVitalsTrend(rows) {
|
|
3779
|
+
const byDate = new Map();
|
|
3780
|
+
for (const row of rows) {
|
|
3781
|
+
const metrics = safeJsonParse(row.metrics_json, {});
|
|
3782
|
+
const bucket = byDate.get(row.date_key) ?? {
|
|
3783
|
+
restingHeartRate: [],
|
|
3784
|
+
vo2Max: []
|
|
3785
|
+
};
|
|
3786
|
+
const restingHeartRate = pickVitalMetricValue(metrics, [
|
|
3787
|
+
"restingHeartRate",
|
|
3788
|
+
"resting_heart_rate"
|
|
3789
|
+
]);
|
|
3790
|
+
const vo2Max = pickVitalMetricValue(metrics, [
|
|
3791
|
+
"vo2Max",
|
|
3792
|
+
"vo2max",
|
|
3793
|
+
"vo2_max"
|
|
3794
|
+
]);
|
|
3795
|
+
if (restingHeartRate != null) {
|
|
3796
|
+
bucket.restingHeartRate.push(restingHeartRate);
|
|
3797
|
+
}
|
|
3798
|
+
if (vo2Max != null) {
|
|
3799
|
+
bucket.vo2Max.push(vo2Max);
|
|
3800
|
+
}
|
|
3801
|
+
byDate.set(row.date_key, bucket);
|
|
3802
|
+
}
|
|
3803
|
+
return [...byDate.entries()]
|
|
3804
|
+
.sort((left, right) => left[0].localeCompare(right[0]))
|
|
3805
|
+
.slice(-90)
|
|
3806
|
+
.map(([dateKey, values]) => ({
|
|
3807
|
+
dateKey,
|
|
3808
|
+
restingHeartRate: values.restingHeartRate.length > 0
|
|
3809
|
+
? round(average(values.restingHeartRate), 1)
|
|
3810
|
+
: null,
|
|
3811
|
+
vo2Max: values.vo2Max.length > 0 ? round(average(values.vo2Max), 2) : null
|
|
3812
|
+
}));
|
|
3813
|
+
}
|
|
3572
3814
|
export function getFitnessViewData(userIds) {
|
|
3573
3815
|
const workouts = listWorkoutRows(userIds).map(mapWorkoutSession);
|
|
3816
|
+
const vitalsTrend = buildFitnessVitalsTrend(listDailySummaryRows("vitals", userIds));
|
|
3574
3817
|
const recent = workouts.slice(0, 40);
|
|
3575
3818
|
const weekly = recent.filter((session) => Date.now() - Date.parse(session.startedAt) <= 7 * 24 * 60 * 60 * 1000);
|
|
3576
3819
|
const weeklyVolumeSeconds = weekly.reduce((sum, session) => sum + session.durationSeconds, 0);
|
|
@@ -3684,6 +3927,8 @@ export function getFitnessViewData(userIds) {
|
|
|
3684
3927
|
totalMinutes: metrics.totalMinutes,
|
|
3685
3928
|
energyKcal: metrics.energyKcal
|
|
3686
3929
|
})),
|
|
3930
|
+
vitalsTrend,
|
|
3931
|
+
analysisSessions: workouts,
|
|
3687
3932
|
sessions: recent
|
|
3688
3933
|
};
|
|
3689
3934
|
}
|