forge-openclaw-plugin 0.2.93 → 0.2.94
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-DKxKOwax.js → board-D1HbyD4u.js} +1 -1
- package/dist/assets/{index-BNvUaA6y.js → index-DWZd3qT-.js} +44 -44
- package/dist/assets/index-PA_Ih223.css +1 -0
- package/dist/assets/{motion-CM4AfIqo.js → motion-D2OqILg_.js} +1 -1
- package/dist/assets/{table-BUeQ9wzR.js → table-YWWjPjC_.js} +1 -1
- package/dist/assets/{ui-3Wd4pVaA.js → ui-DikPZj8S.js} +1 -1
- package/dist/assets/vendor-BS9OPVNh.js +2181 -0
- package/dist/companion-iroh/darwin-arm64/forge-companion-iroh +0 -0
- package/dist/companion-iroh/darwin-x64/forge-companion-iroh +0 -0
- package/dist/companion-iroh/linux-x64/forge-companion-iroh +0 -0
- package/dist/index.html +7 -7
- package/dist/openclaw/parity.js +1 -0
- package/dist/openclaw/routes.js +5 -0
- package/dist/openclaw/tools.js +7 -0
- package/dist/server/server/src/app.js +64 -2
- package/dist/server/server/src/health.js +337 -2
- package/dist/server/server/src/openapi.js +59 -0
- package/dist/server/src/lib/api.js +6 -0
- package/openclaw.plugin.json +2 -1
- package/package.json +1 -1
- package/skills/forge-openclaw/SKILL.md +11 -7
- package/skills/forge-openclaw/entity_conversation_playbooks.md +69 -12
- package/dist/assets/index-NqIbz_lv.css +0 -1
- package/dist/assets/vendor-BVU0cZC9.js +0 -2171
|
Binary file
|
|
Binary file
|
|
Binary file
|
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-DWZd3qT-.js"></script>
|
|
17
|
+
<link rel="modulepreload" crossorigin href="/forge/assets/vendor-BS9OPVNh.js">
|
|
18
|
+
<link rel="modulepreload" crossorigin href="/forge/assets/board-D1HbyD4u.js">
|
|
19
|
+
<link rel="modulepreload" crossorigin href="/forge/assets/ui-DikPZj8S.js">
|
|
20
|
+
<link rel="modulepreload" crossorigin href="/forge/assets/motion-D2OqILg_.js">
|
|
21
|
+
<link rel="modulepreload" crossorigin href="/forge/assets/table-YWWjPjC_.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-PA_Ih223.css">
|
|
24
24
|
</head>
|
|
25
25
|
<body class="bg-canvas text-ink antialiased">
|
|
26
26
|
<div id="root"></div>
|
package/dist/openclaw/parity.js
CHANGED
|
@@ -30,6 +30,7 @@ export const FORGE_SUPPORTED_PLUGIN_API_ROUTES = [
|
|
|
30
30
|
{ method: "GET", path: "/api/v1/health/sleep", purpose: "health" },
|
|
31
31
|
{ method: "PATCH", path: "/api/v1/health/sleep/:id", purpose: "health" },
|
|
32
32
|
{ method: "GET", path: "/api/v1/health/fitness", purpose: "health" },
|
|
33
|
+
{ method: "GET", path: "/api/v1/health/training-load", purpose: "health" },
|
|
33
34
|
{ method: "PATCH", path: "/api/v1/health/workouts/:id", purpose: "health" },
|
|
34
35
|
{ method: "GET", path: "/api/v1/movement/day", purpose: "movement" },
|
|
35
36
|
{ method: "GET", path: "/api/v1/movement/month", purpose: "movement" },
|
package/dist/openclaw/routes.js
CHANGED
|
@@ -176,6 +176,11 @@ export const FORGE_PLUGIN_ROUTE_GROUPS = [
|
|
|
176
176
|
upstreamPath: "/api/v1/health/fitness",
|
|
177
177
|
target: (_match, url) => passthroughSearch("/api/v1/health/fitness", url)
|
|
178
178
|
}),
|
|
179
|
+
exact("/forge/v1/health/training-load", {
|
|
180
|
+
method: "GET",
|
|
181
|
+
upstreamPath: "/api/v1/health/training-load",
|
|
182
|
+
target: (_match, url) => passthroughSearch("/api/v1/health/training-load", url)
|
|
183
|
+
}),
|
|
179
184
|
{
|
|
180
185
|
path: "/forge/v1/movement",
|
|
181
186
|
match: "prefix",
|
package/dist/openclaw/tools.js
CHANGED
|
@@ -752,6 +752,13 @@ export function registerForgePluginTools(api, config) {
|
|
|
752
752
|
parameters: scopedReadSchema,
|
|
753
753
|
path: (params) => withUserIds("/api/v1/health/fitness", params.userIds)
|
|
754
754
|
});
|
|
755
|
+
registerReadTool(api, config, {
|
|
756
|
+
name: "forge_get_training_load_overview",
|
|
757
|
+
label: "Forge Training Load Overview",
|
|
758
|
+
description: "Read the cardiovascular training-load surface with acute/chronic load, HR zone distribution, weekly intensity targets, and data-quality flags.",
|
|
759
|
+
parameters: scopedReadSchema,
|
|
760
|
+
path: (params) => withUserIds("/api/v1/health/training-load", params.userIds)
|
|
761
|
+
});
|
|
755
762
|
api.registerTool({
|
|
756
763
|
name: "forge_update_sleep_session",
|
|
757
764
|
label: "Forge Update Sleep Session",
|
|
@@ -66,7 +66,7 @@ import { registerWebRoutes } from "./web.js";
|
|
|
66
66
|
import { createManagerRuntime } from "./managers/runtime.js";
|
|
67
67
|
import { isManagerError } from "./managers/type-guards.js";
|
|
68
68
|
import { buildCompanionPairingTransport, getCompanionIrohStatus, stopCompanionIroh } from "./services/companion-iroh.js";
|
|
69
|
-
import { createCompanionPairingSession, createCompanionPairingSessionSchema, createSleepSession, createSleepSessionSchema, createWorkoutSession, createWorkoutSessionSchema, deleteSleepSession, deleteWorkoutSession, getCompanionPairingSessionById, getCompanionOverview, getFitnessViewData, getSleepSessionById, getSleepSessionDetailById, getSleepTimelineOverlaysForRange, getSleepViewData, getVitalsViewData, getHealthZoneProfileForUser, getMobileHealthSyncSessionStatus, getWorkoutSessionById, getWorkoutSessionDetailById, heartbeatCompanionPairing, heartbeatCompanionPairingSchema, healthZoneProfilePatchSchema, abortMobileHealthSyncSession, completeMobileHealthSyncSession, ingestMobileHealthSync, ingestMobileHealthSyncChunk, mobileHealthSyncChunkSchema, mobileHealthSyncSessionCompleteSchema, mobileHealthSyncSessionStartSchema, mobileHealthSyncSchema, patchHealthZoneProfileForUser, patchCompanionPairingSourceState, patchCompanionPairingSourceStateSchema, companionSourceKeySchema, requireValidPairing, startMobileHealthSyncSession, revokeAllCompanionPairingSessions, revokeAllCompanionPairingSessionsSchema, revokeCompanionPairingSession, updateMobileCompanionSourceState, updateMobileCompanionSourceStateSchema, verifyCompanionPairing, verifyCompanionPairingSchema, updateSleepMetadata, updateSleepMetadataSchema, updateWorkoutMetadata, updateWorkoutMetadataSchema } from "./health.js";
|
|
69
|
+
import { createCompanionPairingSession, createCompanionPairingSessionSchema, createSleepSession, createSleepSessionSchema, createWorkoutSession, createWorkoutSessionSchema, deleteSleepSession, deleteWorkoutSession, getCompanionPairingSessionById, getCompanionOverview, getFitnessViewData, getSleepSessionById, getSleepSessionDetailById, getSleepTimelineOverlaysForRange, getSleepViewData, getTrainingLoadViewData, getVitalsViewData, getHealthZoneProfileForUser, getMobileHealthSyncSessionStatus, getWorkoutSessionById, getWorkoutSessionDetailById, heartbeatCompanionPairing, heartbeatCompanionPairingSchema, healthZoneProfilePatchSchema, abortMobileHealthSyncSession, completeMobileHealthSyncSession, ingestMobileHealthSync, ingestMobileHealthSyncChunk, mobileHealthSyncChunkSchema, mobileHealthSyncSessionCompleteSchema, mobileHealthSyncSessionStartSchema, mobileHealthSyncSchema, patchHealthZoneProfileForUser, patchCompanionPairingSourceState, patchCompanionPairingSourceStateSchema, companionSourceKeySchema, requireValidPairing, startMobileHealthSyncSession, revokeAllCompanionPairingSessions, revokeAllCompanionPairingSessionsSchema, revokeCompanionPairingSession, updateMobileCompanionSourceState, updateMobileCompanionSourceStateSchema, verifyCompanionPairing, verifyCompanionPairingSchema, updateSleepMetadata, updateSleepMetadataSchema, updateWorkoutMetadata, updateWorkoutMetadataSchema } from "./health.js";
|
|
70
70
|
import { analyzeMovementUserBoxPreflight, createMovementUserBox, createMovementPlace, deleteMovementUserBox, getMovementAllTimeSummary, getMovementBoxDetail, getMovementDayDetail, getMovementMobileBootstrap, getMovementTimeline, getMovementSelectionAggregate, getMovementSettings, getMovementTripDetail, getMovementMonthSummary, invalidateAutomaticMovementBox, listMovementPlaces, movementAutomaticBoxInvalidateSchema, movementMobileBootstrapSchema, movementMobilePlaceMutationSchema, movementMobileStayPatchSchema, movementMobileUserBoxCreateSchema, movementMobileUserBoxPreflightSchema, movementMobileUserBoxPatchSchema, movementMobileAutomaticBoxInvalidateSchema, movementMobileTimelineSchema, movementPlaceMutationSchema, movementPlacePatchSchema, movementSelectionAggregateSchema, movementStayPatchSchema, movementTripPatchSchema, movementUserBoxCreateSchema, movementUserBoxPreflightSchema, movementUserBoxPatchSchema, movementSettingsPatchSchema, movementTimelineQuerySchema, movementTripPointPatchSchema, deleteMovementStay, deleteMovementTrip, deleteMovementTripPoint, updateMovementPlace, updateMovementSettings, updateMovementStay, updateMovementTrip, updateMovementUserBox, updateMovementTripPoint, resolveMovementTimelineSegmentForBox } from "./movement.js";
|
|
71
71
|
import { getScreenTimeAllTimeSummary, getScreenTimeDayDetail, getScreenTimeMonthSummary, getScreenTimeSettings, screenTimeSettingsPatchSchema, updateScreenTimeSettings } from "./screen-time.js";
|
|
72
72
|
import { assertWatchReady, buildWatchBootstrap, ingestWatchCaptureBatch, mobileWatchBootstrapSchema, mobileWatchCaptureBatchSchema, mobileWatchHabitCheckInSchema } from "./watch-mobile.js";
|
|
@@ -2246,6 +2246,8 @@ function buildPreferredMutationPath(entityType) {
|
|
|
2246
2246
|
return "Read-only surface. Use batch CRUD for sleep_session records or the review enrichment route for reflective notes.";
|
|
2247
2247
|
case "sports_overview":
|
|
2248
2248
|
return "Read-only surface. Use batch CRUD for workout_session records or the review enrichment route for reflective notes.";
|
|
2249
|
+
case "training_load":
|
|
2250
|
+
return "Read-only surface. Use it for cardiovascular load, HR zones, acute/chronic stress, VO2max context, and target analysis; use batch CRUD for underlying workout_session records.";
|
|
2249
2251
|
default:
|
|
2250
2252
|
return "Read-only surface.";
|
|
2251
2253
|
}
|
|
@@ -2317,6 +2319,8 @@ function buildPreferredReadPath(entityType) {
|
|
|
2317
2319
|
return "/api/v1/health/sleep";
|
|
2318
2320
|
case "sports_overview":
|
|
2319
2321
|
return "/api/v1/health/fitness";
|
|
2322
|
+
case "training_load":
|
|
2323
|
+
return "/api/v1/health/training-load";
|
|
2320
2324
|
default:
|
|
2321
2325
|
return null;
|
|
2322
2326
|
}
|
|
@@ -3037,6 +3041,19 @@ const AGENT_ONBOARDING_ENTITY_CATALOG = [
|
|
|
3037
3041
|
"Read this surface before suggesting workout reflections or recovery follow-up."
|
|
3038
3042
|
],
|
|
3039
3043
|
fieldGuide: []
|
|
3044
|
+
}),
|
|
3045
|
+
enrichOnboardingEntityGuide({
|
|
3046
|
+
entityType: "training_load",
|
|
3047
|
+
purpose: "The read-model cardiovascular training-load workspace for acute/chronic load, HR zone distribution, intensity targets, and VO2max context.",
|
|
3048
|
+
minimumCreateFields: [],
|
|
3049
|
+
relationshipRules: [
|
|
3050
|
+
"Use this surface for review and interpretation.",
|
|
3051
|
+
"Create, update, delete, or search the underlying workout_session records through batch CRUD by default."
|
|
3052
|
+
],
|
|
3053
|
+
searchHints: [
|
|
3054
|
+
"Read this surface before advising on high-intensity balance, recovery load, or cardiovascular training targets."
|
|
3055
|
+
],
|
|
3056
|
+
fieldGuide: []
|
|
3040
3057
|
})
|
|
3041
3058
|
];
|
|
3042
3059
|
const AGENT_ONBOARDING_CONVERSATION_RULES = [
|
|
@@ -3082,7 +3099,7 @@ const AGENT_ONBOARDING_CONVERSATION_RULES = [
|
|
|
3082
3099
|
"Once the route family is clear, say it plainly enough that another agent could follow the same path without guessing.",
|
|
3083
3100
|
"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.",
|
|
3084
3101
|
"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.",
|
|
3085
|
-
"For read-model-only health surfaces such as sleep_overview and
|
|
3102
|
+
"For read-model-only health surfaces such as sleep_overview, sports_overview, and training_load, use the dedicated overview reads first when the user wants review, pattern interpretation, recovery context, training-load context, or cardiovascular target analysis. Move to sleep_session or workout_session writes only after one specific stored session needs enrichment.",
|
|
3086
3103
|
"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.",
|
|
3087
3104
|
"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.",
|
|
3088
3105
|
"For meaning-bearing updates, especially in Psyche, briefly say what feels newly true before you ask for the one structural detail that still changes the save."
|
|
@@ -3375,6 +3392,18 @@ const AGENT_ONBOARDING_ENTITY_CONVERSATION_PLAYBOOKS = [
|
|
|
3375
3392
|
"Move to a workout_session write only when one specific workout needs reflective context, tags, notes, or links."
|
|
3376
3393
|
]
|
|
3377
3394
|
},
|
|
3395
|
+
{
|
|
3396
|
+
focus: "training_load",
|
|
3397
|
+
openingQuestion: "What training decision should the load picture help with right now?",
|
|
3398
|
+
coachingGoal: "Review cardiovascular load, HR zones, acute/chronic stress, high-intensity pressure, VO2max context, and target distribution before suggesting training changes.",
|
|
3399
|
+
askSequence: [
|
|
3400
|
+
"Ask what decision the user wants from the load surface only if it is not already clear.",
|
|
3401
|
+
"Use the dedicated training-load overview before asking the user to reconstruct workout metrics from memory.",
|
|
3402
|
+
"Separate data-quality limits from true physiological interpretation.",
|
|
3403
|
+
"Translate the load pattern into a concrete next training constraint or target.",
|
|
3404
|
+
"Move to a workout_session write only when one specific workout needs reflective context, tags, notes, or links."
|
|
3405
|
+
]
|
|
3406
|
+
},
|
|
3378
3407
|
{
|
|
3379
3408
|
focus: "preference_catalog",
|
|
3380
3409
|
openingQuestion: "What decision or taste question should this catalog help with?",
|
|
@@ -4230,6 +4259,19 @@ const AGENT_ONBOARDING_TOOL_INPUT_CATALOG = [
|
|
|
4230
4259
|
],
|
|
4231
4260
|
example: '{"userIds":["user_operator"]}'
|
|
4232
4261
|
},
|
|
4262
|
+
{
|
|
4263
|
+
toolName: "forge_get_training_load_overview",
|
|
4264
|
+
summary: "Read the cardiovascular training-load surface with acute/chronic load, HR zone distribution, weekly intensity targets, and data-quality flags.",
|
|
4265
|
+
whenToUse: "Use when the operator wants to analyze training stress, zone balance, VO2max context, combat-sport load, or what to optimize next.",
|
|
4266
|
+
inputShape: "{ userIds?: string[] }",
|
|
4267
|
+
requiredFields: [],
|
|
4268
|
+
notes: [
|
|
4269
|
+
"The API path is /api/v1/health/training-load and the UI route is /training-load.",
|
|
4270
|
+
"This is a read-model-only surface. Workout records remain ordinary workout_session entities for batch CRUD.",
|
|
4271
|
+
"Forge uses HRR zone analytics and TRIMP-like internal load from stored workout evidence."
|
|
4272
|
+
],
|
|
4273
|
+
example: '{"userIds":["user_operator"]}'
|
|
4274
|
+
},
|
|
4233
4275
|
{
|
|
4234
4276
|
toolName: "forge_update_sleep_session",
|
|
4235
4277
|
summary: "Patch one sleep session with reflective notes, tags, or linked Forge context.",
|
|
@@ -5013,6 +5055,8 @@ function buildAgentOnboardingPayload(request) {
|
|
|
5013
5055
|
sleep_overview: "/api/v1/health/sleep",
|
|
5014
5056
|
sportsOverview: "/api/v1/health/fitness",
|
|
5015
5057
|
sports_overview: "/api/v1/health/fitness",
|
|
5058
|
+
trainingLoad: "/api/v1/health/training-load",
|
|
5059
|
+
training_load: "/api/v1/health/training-load",
|
|
5016
5060
|
selfObservation: "/api/v1/psyche/self-observation/calendar",
|
|
5017
5061
|
self_observation: "/api/v1/psyche/self-observation/calendar",
|
|
5018
5062
|
calendarOverview: "/api/v1/calendar/overview",
|
|
@@ -5122,6 +5166,7 @@ function buildAgentOnboardingPayload(request) {
|
|
|
5122
5166
|
weeklyReview: "/api/v1/reviews/weekly",
|
|
5123
5167
|
sleepOverview: "/api/v1/health/sleep",
|
|
5124
5168
|
sportsOverview: "/api/v1/health/fitness",
|
|
5169
|
+
trainingLoad: "/api/v1/health/training-load",
|
|
5125
5170
|
lifeForce: "/api/v1/life-force",
|
|
5126
5171
|
lifeForceProfile: "/api/v1/life-force/profile",
|
|
5127
5172
|
lifeForceWeekdayTemplate: "/api/v1/life-force/templates/:weekday",
|
|
@@ -5173,6 +5218,7 @@ function buildAgentOnboardingPayload(request) {
|
|
|
5173
5218
|
"forge_get_psyche_overview",
|
|
5174
5219
|
"forge_get_sleep_overview",
|
|
5175
5220
|
"forge_get_sports_overview",
|
|
5221
|
+
"forge_get_training_load_overview",
|
|
5176
5222
|
"forge_get_xp_metrics",
|
|
5177
5223
|
"forge_get_weekly_review"
|
|
5178
5224
|
],
|
|
@@ -5203,6 +5249,7 @@ function buildAgentOnboardingPayload(request) {
|
|
|
5203
5249
|
healthWorkflow: [
|
|
5204
5250
|
"forge_get_sleep_overview",
|
|
5205
5251
|
"forge_get_sports_overview",
|
|
5252
|
+
"forge_get_training_load_overview",
|
|
5206
5253
|
"forge_update_sleep_session",
|
|
5207
5254
|
"forge_update_workout_session"
|
|
5208
5255
|
],
|
|
@@ -6210,6 +6257,16 @@ function compactFitness(fitness) {
|
|
|
6210
6257
|
detailRoute: "/api/v1/health/fitness"
|
|
6211
6258
|
};
|
|
6212
6259
|
}
|
|
6260
|
+
function compactTrainingLoad(trainingLoad) {
|
|
6261
|
+
return {
|
|
6262
|
+
summary: trainingLoad.summary,
|
|
6263
|
+
intensityDistribution: trainingLoad.recentIntensityDistribution,
|
|
6264
|
+
weeklyLoad: trainingLoad.weeklyLoad.slice(-8),
|
|
6265
|
+
topActivities: trainingLoad.activityBreakdown.slice(0, 6),
|
|
6266
|
+
targetModel: trainingLoad.targetModel,
|
|
6267
|
+
detailRoute: "/api/v1/health/training-load"
|
|
6268
|
+
};
|
|
6269
|
+
}
|
|
6213
6270
|
function compactVitals(vitals) {
|
|
6214
6271
|
return {
|
|
6215
6272
|
summary: vitals.summary,
|
|
@@ -6490,6 +6547,7 @@ function buildOperatorOverview(request) {
|
|
|
6490
6547
|
const yesterday = compactDailyContext(getTodayContext(addDays(now, -1), { userIds }));
|
|
6491
6548
|
const sleep = compactSleep(getSleepViewData(userIds));
|
|
6492
6549
|
const fitness = compactFitness(getFitnessViewData(userIds));
|
|
6550
|
+
const trainingLoad = compactTrainingLoad(getTrainingLoadViewData(userIds));
|
|
6493
6551
|
const vitals = compactVitals(getVitalsViewData(userIds));
|
|
6494
6552
|
const lifeForce = compactLifeForce(buildLifeForcePayload(now, userIds));
|
|
6495
6553
|
const psyche = canReadPsyche ? compactPsyche(getPsycheOverview(userIds)) : null;
|
|
@@ -6571,6 +6629,7 @@ function buildOperatorOverview(request) {
|
|
|
6571
6629
|
notes,
|
|
6572
6630
|
sleep,
|
|
6573
6631
|
fitness,
|
|
6632
|
+
trainingLoad,
|
|
6574
6633
|
vitals,
|
|
6575
6634
|
lifeForce,
|
|
6576
6635
|
domains: listDomains().map((domain) => ({
|
|
@@ -7249,6 +7308,9 @@ export async function buildServer(options = {}) {
|
|
|
7249
7308
|
app.get("/api/v1/health/fitness", async (request) => ({
|
|
7250
7309
|
fitness: getFitnessViewData(resolveScopedUserIds(request.query))
|
|
7251
7310
|
}));
|
|
7311
|
+
app.get("/api/v1/health/training-load", async (request) => ({
|
|
7312
|
+
trainingLoad: getTrainingLoadViewData(resolveScopedUserIds(request.query))
|
|
7313
|
+
}));
|
|
7252
7314
|
app.get("/api/v1/health/vitals", async (request) => ({
|
|
7253
7315
|
vitals: getVitalsViewData(resolveScopedUserIds(request.query))
|
|
7254
7316
|
}));
|
|
@@ -4,7 +4,7 @@ import { z } from "zod";
|
|
|
4
4
|
import { getDatabase, runInTransaction } from "./db.js";
|
|
5
5
|
import { HttpError } from "./errors.js";
|
|
6
6
|
import { buildWorkoutSessionPersistenceSeed, buildWorkoutSessionPresentation, workoutActivityDescriptorSchema, workoutDetailsSchema } from "./health-workout-adapters.js";
|
|
7
|
-
import { getHealthZoneProfile, getStoredWorkoutAnalytics, getWorkoutRawEvidence, healthZoneProfilePatchSchema, recomputeAndStoreWorkoutAnalytics, upsertWorkoutRoutePoints, upsertWorkoutTimeSeries, workoutCaptureQualitySchema, workoutRoutePointSchema, workoutTimeSeriesSampleSchema, patchHealthZoneProfile } from "./health-workout-analytics.js";
|
|
7
|
+
import { WORKOUT_ZONE_ORDER, getHealthZoneProfile, getStoredWorkoutAnalytics, getWorkoutRawEvidence, healthZoneProfilePatchSchema, recomputeAndStoreWorkoutAnalytics, upsertWorkoutRoutePoints, upsertWorkoutTimeSeries, workoutCaptureQualitySchema, workoutRoutePointSchema, workoutTimeSeriesSampleSchema, patchHealthZoneProfile } from "./health-workout-analytics.js";
|
|
8
8
|
import { getMovementMobileBootstrap, ingestMovementSync, movementSyncPayloadSchema } from "./movement.js";
|
|
9
9
|
import { ingestScreenTimeSync, screenTimeSyncPayloadSchema } from "./screen-time.js";
|
|
10
10
|
import { recordActivityEvent } from "./repositories/activity-events.js";
|
|
@@ -2758,9 +2758,11 @@ function expectedWorkoutEvidenceCounts(derived) {
|
|
|
2758
2758
|
const syncRoutePointCount = finiteNumberFromUnknown(syncCursor.routePointCount);
|
|
2759
2759
|
const captureRoutePointCount = finiteNumberFromUnknown(captureQuality.routePoints);
|
|
2760
2760
|
const expectedTimeSeriesSamples = Math.max(0, Math.ceil(Math.max(syncTimeSeriesCount ?? 0, captureHeartRateCount ?? 0)));
|
|
2761
|
+
const expectedHeartRateSamples = Math.max(0, Math.ceil(captureHeartRateCount ?? 0));
|
|
2761
2762
|
const expectedRoutePoints = Math.max(0, Math.ceil(Math.max(syncRoutePointCount ?? 0, captureRoutePointCount ?? 0)));
|
|
2762
2763
|
return {
|
|
2763
2764
|
expectedTimeSeriesSamples,
|
|
2765
|
+
expectedHeartRateSamples,
|
|
2764
2766
|
expectedRoutePoints,
|
|
2765
2767
|
hasEvidenceMetadata: syncTimeSeriesCount !== null ||
|
|
2766
2768
|
captureHeartRateCount !== null ||
|
|
@@ -2775,6 +2777,12 @@ function mobileHealthWorkoutImportState(userId) {
|
|
|
2775
2777
|
FROM health_workout_time_series
|
|
2776
2778
|
GROUP BY workout_id
|
|
2777
2779
|
),
|
|
2780
|
+
heart_rate_counts AS (
|
|
2781
|
+
SELECT workout_id, COUNT(*) AS heart_rate_count
|
|
2782
|
+
FROM health_workout_time_series
|
|
2783
|
+
WHERE metric_key = 'heart_rate'
|
|
2784
|
+
GROUP BY workout_id
|
|
2785
|
+
),
|
|
2778
2786
|
route_counts AS (
|
|
2779
2787
|
SELECT workout_id, COUNT(*) AS route_point_count
|
|
2780
2788
|
FROM health_workout_routes
|
|
@@ -2784,9 +2792,11 @@ function mobileHealthWorkoutImportState(userId) {
|
|
|
2784
2792
|
w.external_uid,
|
|
2785
2793
|
w.derived_json,
|
|
2786
2794
|
COALESCE(time_series_counts.time_series_count, 0) AS time_series_count,
|
|
2795
|
+
COALESCE(heart_rate_counts.heart_rate_count, 0) AS heart_rate_count,
|
|
2787
2796
|
COALESCE(route_counts.route_point_count, 0) AS route_point_count
|
|
2788
2797
|
FROM health_workout_sessions w
|
|
2789
2798
|
LEFT JOIN time_series_counts ON time_series_counts.workout_id = w.id
|
|
2799
|
+
LEFT JOIN heart_rate_counts ON heart_rate_counts.workout_id = w.id
|
|
2790
2800
|
LEFT JOIN route_counts ON route_counts.workout_id = w.id
|
|
2791
2801
|
WHERE w.user_id = ?
|
|
2792
2802
|
AND w.source = 'apple_health'
|
|
@@ -2797,19 +2807,23 @@ function mobileHealthWorkoutImportState(userId) {
|
|
|
2797
2807
|
const alreadyUploadedWorkoutExternalUids = [];
|
|
2798
2808
|
let incompleteWorkoutCount = 0;
|
|
2799
2809
|
let timeSeriesSampleCount = 0;
|
|
2810
|
+
let heartRateSampleCount = 0;
|
|
2800
2811
|
let routePointCount = 0;
|
|
2801
2812
|
for (const row of rows) {
|
|
2802
2813
|
const derived = safeJsonParse(row.derived_json, {});
|
|
2803
2814
|
const evidenceCounts = expectedWorkoutEvidenceCounts(derived);
|
|
2804
2815
|
const actualTimeSeriesCount = Math.max(0, row.time_series_count ?? 0);
|
|
2816
|
+
const actualHeartRateCount = Math.max(0, row.heart_rate_count ?? 0);
|
|
2805
2817
|
const actualRoutePointCount = Math.max(0, row.route_point_count ?? 0);
|
|
2806
2818
|
const evidenceComplete = evidenceCounts.hasEvidenceMetadata
|
|
2807
2819
|
? actualTimeSeriesCount >= evidenceCounts.expectedTimeSeriesSamples &&
|
|
2820
|
+
actualHeartRateCount >= evidenceCounts.expectedHeartRateSamples &&
|
|
2808
2821
|
actualRoutePointCount >= evidenceCounts.expectedRoutePoints
|
|
2809
|
-
:
|
|
2822
|
+
: false;
|
|
2810
2823
|
if (evidenceComplete) {
|
|
2811
2824
|
alreadyUploadedWorkoutExternalUids.push(row.external_uid.toLowerCase());
|
|
2812
2825
|
timeSeriesSampleCount += actualTimeSeriesCount;
|
|
2826
|
+
heartRateSampleCount += actualHeartRateCount;
|
|
2813
2827
|
routePointCount += actualRoutePointCount;
|
|
2814
2828
|
}
|
|
2815
2829
|
else {
|
|
@@ -2822,6 +2836,7 @@ function mobileHealthWorkoutImportState(userId) {
|
|
|
2822
2836
|
existingWorkoutCount: rows.length,
|
|
2823
2837
|
incompleteWorkoutCount,
|
|
2824
2838
|
timeSeriesSampleCount,
|
|
2839
|
+
heartRateSampleCount,
|
|
2825
2840
|
routePointCount,
|
|
2826
2841
|
capturedAt: nowIso()
|
|
2827
2842
|
};
|
|
@@ -4062,6 +4077,326 @@ function buildFitnessVitalsTrend(rows) {
|
|
|
4062
4077
|
vo2Max: values.vo2Max.length > 0 ? round(average(values.vo2Max), 2) : null
|
|
4063
4078
|
}));
|
|
4064
4079
|
}
|
|
4080
|
+
function isoWeekKey(value) {
|
|
4081
|
+
const date = new Date(value);
|
|
4082
|
+
const day = date.getUTCDay() || 7;
|
|
4083
|
+
date.setUTCDate(date.getUTCDate() + 4 - day);
|
|
4084
|
+
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
|
|
4085
|
+
const week = Math.ceil(((date.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7);
|
|
4086
|
+
return `${date.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
|
|
4087
|
+
}
|
|
4088
|
+
function addDays(date, days) {
|
|
4089
|
+
const next = new Date(date);
|
|
4090
|
+
next.setUTCDate(next.getUTCDate() + days);
|
|
4091
|
+
return next;
|
|
4092
|
+
}
|
|
4093
|
+
function dateKeyFromDate(date) {
|
|
4094
|
+
return date.toISOString().slice(0, 10);
|
|
4095
|
+
}
|
|
4096
|
+
function standardDeviation(values) {
|
|
4097
|
+
if (values.length <= 1) {
|
|
4098
|
+
return 0;
|
|
4099
|
+
}
|
|
4100
|
+
const mean = average(values);
|
|
4101
|
+
const variance = values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / values.length;
|
|
4102
|
+
return Math.sqrt(variance);
|
|
4103
|
+
}
|
|
4104
|
+
function zoneSeconds(session, keys) {
|
|
4105
|
+
const zones = session.analytics
|
|
4106
|
+
?.zoneDurations ?? [];
|
|
4107
|
+
return zones
|
|
4108
|
+
.filter((zone) => keys.includes(zone.key))
|
|
4109
|
+
.reduce((sum, zone) => sum + zone.seconds, 0);
|
|
4110
|
+
}
|
|
4111
|
+
function workoutLoad(session) {
|
|
4112
|
+
return (session.analytics?.load
|
|
4113
|
+
?.trimp ?? 0);
|
|
4114
|
+
}
|
|
4115
|
+
function workoutIntensity(session) {
|
|
4116
|
+
return (session.analytics
|
|
4117
|
+
?.load?.intensity ?? null);
|
|
4118
|
+
}
|
|
4119
|
+
function workoutHrCoverage(session) {
|
|
4120
|
+
return (session.analytics?.dataQuality?.sampleCoverage ?? 0);
|
|
4121
|
+
}
|
|
4122
|
+
function workoutHrSampleCount(session) {
|
|
4123
|
+
return (session.analytics?.dataQuality?.heartRateSampleCount ?? 0);
|
|
4124
|
+
}
|
|
4125
|
+
function workoutAverageHr(session) {
|
|
4126
|
+
return (session.analytics
|
|
4127
|
+
?.hrSummary?.averageHr ?? session.averageHeartRate);
|
|
4128
|
+
}
|
|
4129
|
+
function workoutMaxHr(session) {
|
|
4130
|
+
return (session.analytics
|
|
4131
|
+
?.hrSummary?.maxHr ?? session.maxHeartRate);
|
|
4132
|
+
}
|
|
4133
|
+
function summarizeZoneDistribution(sessions) {
|
|
4134
|
+
const totals = new Map();
|
|
4135
|
+
let totalSeconds = 0;
|
|
4136
|
+
for (const session of sessions) {
|
|
4137
|
+
const zones = session.analytics?.zoneDurations ?? [];
|
|
4138
|
+
for (const zone of zones) {
|
|
4139
|
+
const current = totals.get(zone.key) ?? { label: zone.label, seconds: 0 };
|
|
4140
|
+
current.seconds += zone.seconds;
|
|
4141
|
+
totalSeconds += zone.seconds;
|
|
4142
|
+
totals.set(zone.key, current);
|
|
4143
|
+
}
|
|
4144
|
+
}
|
|
4145
|
+
return WORKOUT_ZONE_ORDER.map((key) => {
|
|
4146
|
+
const value = totals.get(key) ?? { label: key.replaceAll("_", " "), seconds: 0 };
|
|
4147
|
+
return {
|
|
4148
|
+
key,
|
|
4149
|
+
label: value.label,
|
|
4150
|
+
seconds: Math.round(value.seconds),
|
|
4151
|
+
percentage: totalSeconds > 0 ? Number((value.seconds / totalSeconds).toFixed(4)) : 0
|
|
4152
|
+
};
|
|
4153
|
+
});
|
|
4154
|
+
}
|
|
4155
|
+
function summarizeIntensityDistribution(sessions) {
|
|
4156
|
+
const lowSeconds = sessions.reduce((sum, session) => sum + zoneSeconds(session, ["below_z1", "zone_1"]), 0);
|
|
4157
|
+
const moderateSeconds = sessions.reduce((sum, session) => sum + zoneSeconds(session, ["zone_2", "zone_3"]), 0);
|
|
4158
|
+
const highSeconds = sessions.reduce((sum, session) => sum + zoneSeconds(session, ["zone_4", "zone_5"]), 0);
|
|
4159
|
+
const totalSeconds = lowSeconds + moderateSeconds + highSeconds;
|
|
4160
|
+
return [
|
|
4161
|
+
{
|
|
4162
|
+
key: "low",
|
|
4163
|
+
label: "Low / base",
|
|
4164
|
+
seconds: Math.round(lowSeconds),
|
|
4165
|
+
percentage: totalSeconds > 0 ? Number((lowSeconds / totalSeconds).toFixed(4)) : 0,
|
|
4166
|
+
targetRange: [0.7, 0.85]
|
|
4167
|
+
},
|
|
4168
|
+
{
|
|
4169
|
+
key: "moderate",
|
|
4170
|
+
label: "Tempo / threshold",
|
|
4171
|
+
seconds: Math.round(moderateSeconds),
|
|
4172
|
+
percentage: totalSeconds > 0
|
|
4173
|
+
? Number((moderateSeconds / totalSeconds).toFixed(4))
|
|
4174
|
+
: 0,
|
|
4175
|
+
targetRange: [0.05, 0.2]
|
|
4176
|
+
},
|
|
4177
|
+
{
|
|
4178
|
+
key: "high",
|
|
4179
|
+
label: "Severe / HIIT",
|
|
4180
|
+
seconds: Math.round(highSeconds),
|
|
4181
|
+
percentage: totalSeconds > 0 ? Number((highSeconds / totalSeconds).toFixed(4)) : 0,
|
|
4182
|
+
targetRange: [0.08, 0.18]
|
|
4183
|
+
}
|
|
4184
|
+
];
|
|
4185
|
+
}
|
|
4186
|
+
function latestVitalValue(vitalsTrend, key) {
|
|
4187
|
+
return [...vitalsTrend].reverse().find((entry) => entry[key] != null)?.[key] ?? null;
|
|
4188
|
+
}
|
|
4189
|
+
export function getTrainingLoadViewData(userIds) {
|
|
4190
|
+
const workoutRows = listWorkoutRows(userIds);
|
|
4191
|
+
const sessions = workoutRows
|
|
4192
|
+
.slice(0, 2000)
|
|
4193
|
+
.map((row) => mapWorkoutSession(row, { includeAnalytics: true }))
|
|
4194
|
+
.sort((left, right) => Date.parse(left.startedAt) - Date.parse(right.startedAt));
|
|
4195
|
+
const now = new Date();
|
|
4196
|
+
const start7 = addDays(now, -6);
|
|
4197
|
+
const start28 = addDays(now, -27);
|
|
4198
|
+
const recent7 = sessions.filter((session) => Date.parse(session.startedAt) >= start7.getTime());
|
|
4199
|
+
const recent28 = sessions.filter((session) => Date.parse(session.startedAt) >= start28.getTime());
|
|
4200
|
+
const vitalsTrend = buildFitnessVitalsTrend(listDailySummaryRows("vitals", userIds));
|
|
4201
|
+
const dailyMap = new Map();
|
|
4202
|
+
for (const session of sessions) {
|
|
4203
|
+
const key = dayKey(session.startedAt);
|
|
4204
|
+
const current = dailyMap.get(key) ?? {
|
|
4205
|
+
dateKey: key,
|
|
4206
|
+
sessionCount: 0,
|
|
4207
|
+
durationSeconds: 0,
|
|
4208
|
+
trainingLoad: 0,
|
|
4209
|
+
highIntensitySeconds: 0,
|
|
4210
|
+
moderateIntensitySeconds: 0,
|
|
4211
|
+
lowIntensitySeconds: 0
|
|
4212
|
+
};
|
|
4213
|
+
current.sessionCount += 1;
|
|
4214
|
+
current.durationSeconds += session.durationSeconds;
|
|
4215
|
+
current.trainingLoad += workoutLoad(session);
|
|
4216
|
+
current.highIntensitySeconds += zoneSeconds(session, ["zone_4", "zone_5"]);
|
|
4217
|
+
current.moderateIntensitySeconds += zoneSeconds(session, ["zone_2", "zone_3"]);
|
|
4218
|
+
current.lowIntensitySeconds += zoneSeconds(session, ["below_z1", "zone_1"]);
|
|
4219
|
+
dailyMap.set(key, current);
|
|
4220
|
+
}
|
|
4221
|
+
const dailyLoad = [...dailyMap.values()]
|
|
4222
|
+
.sort((left, right) => left.dateKey.localeCompare(right.dateKey))
|
|
4223
|
+
.map((day) => ({
|
|
4224
|
+
...day,
|
|
4225
|
+
durationMinutes: Math.round(day.durationSeconds / 60),
|
|
4226
|
+
trainingLoad: round(day.trainingLoad, 1),
|
|
4227
|
+
highIntensityMinutes: round(day.highIntensitySeconds / 60, 1),
|
|
4228
|
+
moderateIntensityMinutes: round(day.moderateIntensitySeconds / 60, 1),
|
|
4229
|
+
lowIntensityMinutes: round(day.lowIntensitySeconds / 60, 1)
|
|
4230
|
+
}));
|
|
4231
|
+
const weekMap = new Map();
|
|
4232
|
+
for (const session of sessions) {
|
|
4233
|
+
const weekKey = isoWeekKey(session.startedAt);
|
|
4234
|
+
const date = new Date(session.startedAt);
|
|
4235
|
+
const day = date.getUTCDay() || 7;
|
|
4236
|
+
const start = addDays(date, 1 - day);
|
|
4237
|
+
const end = addDays(start, 6);
|
|
4238
|
+
const current = weekMap.get(weekKey) ?? {
|
|
4239
|
+
weekKey,
|
|
4240
|
+
startDate: dateKeyFromDate(start),
|
|
4241
|
+
endDate: dateKeyFromDate(end),
|
|
4242
|
+
sessionCount: 0,
|
|
4243
|
+
durationSeconds: 0,
|
|
4244
|
+
trainingLoad: 0,
|
|
4245
|
+
highIntensitySeconds: 0,
|
|
4246
|
+
moderateIntensitySeconds: 0,
|
|
4247
|
+
lowIntensitySeconds: 0
|
|
4248
|
+
};
|
|
4249
|
+
current.sessionCount += 1;
|
|
4250
|
+
current.durationSeconds += session.durationSeconds;
|
|
4251
|
+
current.trainingLoad += workoutLoad(session);
|
|
4252
|
+
current.highIntensitySeconds += zoneSeconds(session, ["zone_4", "zone_5"]);
|
|
4253
|
+
current.moderateIntensitySeconds += zoneSeconds(session, ["zone_2", "zone_3"]);
|
|
4254
|
+
current.lowIntensitySeconds += zoneSeconds(session, ["below_z1", "zone_1"]);
|
|
4255
|
+
weekMap.set(weekKey, current);
|
|
4256
|
+
}
|
|
4257
|
+
const weeklyLoad = [...weekMap.values()]
|
|
4258
|
+
.sort((left, right) => left.weekKey.localeCompare(right.weekKey))
|
|
4259
|
+
.slice(-26)
|
|
4260
|
+
.map((week) => {
|
|
4261
|
+
const totalZoneSeconds = week.lowIntensitySeconds +
|
|
4262
|
+
week.moderateIntensitySeconds +
|
|
4263
|
+
week.highIntensitySeconds;
|
|
4264
|
+
const hours = week.durationSeconds / 3600;
|
|
4265
|
+
return {
|
|
4266
|
+
...week,
|
|
4267
|
+
durationHours: round(hours, 2),
|
|
4268
|
+
trainingLoad: round(week.trainingLoad, 1),
|
|
4269
|
+
loadPerHour: hours > 0 ? round(week.trainingLoad / hours, 1) : 0,
|
|
4270
|
+
lowPercentage: totalZoneSeconds > 0 ? round(week.lowIntensitySeconds / totalZoneSeconds, 3) : 0,
|
|
4271
|
+
moderatePercentage: totalZoneSeconds > 0
|
|
4272
|
+
? round(week.moderateIntensitySeconds / totalZoneSeconds, 3)
|
|
4273
|
+
: 0,
|
|
4274
|
+
highPercentage: totalZoneSeconds > 0 ? round(week.highIntensitySeconds / totalZoneSeconds, 3) : 0,
|
|
4275
|
+
highIntensityMinutes: round(week.highIntensitySeconds / 60, 1)
|
|
4276
|
+
};
|
|
4277
|
+
});
|
|
4278
|
+
const activityBreakdown = [...new Set(sessions.map((session) => session.workoutType))]
|
|
4279
|
+
.map((workoutType) => {
|
|
4280
|
+
const group = sessions.filter((session) => session.workoutType === workoutType);
|
|
4281
|
+
const durationSeconds = group.reduce((sum, session) => sum + session.durationSeconds, 0);
|
|
4282
|
+
const trainingLoad = group.reduce((sum, session) => sum + workoutLoad(session), 0);
|
|
4283
|
+
const highSeconds = group.reduce((sum, session) => sum + zoneSeconds(session, ["zone_4", "zone_5"]), 0);
|
|
4284
|
+
const totalZoneSeconds = group.reduce((sum, session) => sum +
|
|
4285
|
+
zoneSeconds(session, [
|
|
4286
|
+
"below_z1",
|
|
4287
|
+
"zone_1",
|
|
4288
|
+
"zone_2",
|
|
4289
|
+
"zone_3",
|
|
4290
|
+
"zone_4",
|
|
4291
|
+
"zone_5"
|
|
4292
|
+
]), 0);
|
|
4293
|
+
return {
|
|
4294
|
+
workoutType,
|
|
4295
|
+
workoutTypeLabel: group.at(-1)?.workoutTypeLabel ?? workoutType,
|
|
4296
|
+
activityFamily: group.at(-1)?.activityFamily ?? "other",
|
|
4297
|
+
activityFamilyLabel: group.at(-1)?.activityFamilyLabel ?? "Other",
|
|
4298
|
+
sessionCount: group.length,
|
|
4299
|
+
durationHours: round(durationSeconds / 3600, 2),
|
|
4300
|
+
trainingLoad: round(trainingLoad, 1),
|
|
4301
|
+
loadPerHour: durationSeconds > 0 ? round(trainingLoad / (durationSeconds / 3600), 1) : 0,
|
|
4302
|
+
highPercentage: totalZoneSeconds > 0 ? round(highSeconds / totalZoneSeconds, 3) : 0,
|
|
4303
|
+
averageHrCoverage: round(average(group.map((session) => workoutHrCoverage(session))), 3)
|
|
4304
|
+
};
|
|
4305
|
+
})
|
|
4306
|
+
.sort((left, right) => right.trainingLoad - left.trainingLoad);
|
|
4307
|
+
const last7Keys = Array.from({ length: 7 }, (_, index) => dateKeyFromDate(addDays(start7, index)));
|
|
4308
|
+
const last7Loads = last7Keys.map((key) => dailyMap.get(key)?.trainingLoad ?? 0);
|
|
4309
|
+
const acuteLoad7d = recent7.reduce((sum, session) => sum + workoutLoad(session), 0);
|
|
4310
|
+
const chronicWeeklyLoad28d = recent28.reduce((sum, session) => sum + workoutLoad(session), 0) / 4;
|
|
4311
|
+
const loadSd7d = standardDeviation(last7Loads);
|
|
4312
|
+
const monotony7d = loadSd7d > 0 ? average(last7Loads) / loadSd7d : recent7.length > 0 ? null : 0;
|
|
4313
|
+
const strain7d = monotony7d != null ? acuteLoad7d * monotony7d : null;
|
|
4314
|
+
const highSeconds7d = recent7.reduce((sum, session) => sum + zoneSeconds(session, ["zone_4", "zone_5"]), 0);
|
|
4315
|
+
const moderateSeconds7d = recent7.reduce((sum, session) => sum + zoneSeconds(session, ["zone_2", "zone_3"]), 0);
|
|
4316
|
+
const lowSeconds7d = recent7.reduce((sum, session) => sum + zoneSeconds(session, ["below_z1", "zone_1"]), 0);
|
|
4317
|
+
const reliableSessions = sessions.filter((session) => workoutHrSampleCount(session) >= 100 && workoutHrCoverage(session) >= 0.8);
|
|
4318
|
+
const vo2Points = vitalsTrend.filter((entry) => entry.vo2Max != null);
|
|
4319
|
+
const vo2MaxLatest = latestVitalValue(vitalsTrend, "vo2Max");
|
|
4320
|
+
const vo2MaxDelta = vo2Points.length >= 2
|
|
4321
|
+
? round((vo2Points.at(-1)?.vo2Max ?? 0) - (vo2Points[0]?.vo2Max ?? 0), 2)
|
|
4322
|
+
: null;
|
|
4323
|
+
const acwr = chronicWeeklyLoad28d > 0 ? round(acuteLoad7d / chronicWeeklyLoad28d, 2) : null;
|
|
4324
|
+
const readiness = acwr == null
|
|
4325
|
+
? "insufficient_data"
|
|
4326
|
+
: acwr > 1.5 || (strain7d ?? 0) > 450
|
|
4327
|
+
? "overload_watch"
|
|
4328
|
+
: acwr < 0.75
|
|
4329
|
+
? "underloaded"
|
|
4330
|
+
: "productive";
|
|
4331
|
+
return {
|
|
4332
|
+
summary: {
|
|
4333
|
+
sessionCount: sessions.length,
|
|
4334
|
+
reliableSessionCount: reliableSessions.length,
|
|
4335
|
+
totalHours: round(sessions.reduce((sum, session) => sum + session.durationSeconds, 0) / 3600, 1),
|
|
4336
|
+
totalTrainingLoad: round(sessions.reduce((sum, session) => sum + workoutLoad(session), 0), 1),
|
|
4337
|
+
acuteLoad7d: round(acuteLoad7d, 1),
|
|
4338
|
+
chronicWeeklyLoad28d: round(chronicWeeklyLoad28d, 1),
|
|
4339
|
+
acuteChronicRatio: acwr,
|
|
4340
|
+
monotony7d: monotony7d != null ? round(monotony7d, 2) : null,
|
|
4341
|
+
strain7d: strain7d != null ? round(strain7d, 1) : null,
|
|
4342
|
+
highIntensityMinutes7d: round(highSeconds7d / 60, 1),
|
|
4343
|
+
thresholdMinutes7d: round(moderateSeconds7d / 60, 1),
|
|
4344
|
+
easyMinutes7d: round(lowSeconds7d / 60, 1),
|
|
4345
|
+
hardDayCount7d: last7Keys.filter((key) => (dailyMap.get(key)?.highIntensitySeconds ?? 0) >= 10 * 60).length,
|
|
4346
|
+
averageHeartRateCoverage: round(average(sessions.map((session) => workoutHrCoverage(session))), 3),
|
|
4347
|
+
vo2MaxLatest,
|
|
4348
|
+
vo2MaxDelta,
|
|
4349
|
+
latestRestingHeartRate: latestVitalValue(vitalsTrend, "restingHeartRate"),
|
|
4350
|
+
readiness
|
|
4351
|
+
},
|
|
4352
|
+
zoneTotals: summarizeZoneDistribution(sessions),
|
|
4353
|
+
recentZoneTotals: summarizeZoneDistribution(recent28),
|
|
4354
|
+
intensityDistribution: summarizeIntensityDistribution(sessions),
|
|
4355
|
+
recentIntensityDistribution: summarizeIntensityDistribution(recent28),
|
|
4356
|
+
dailyLoad: dailyLoad.slice(-90),
|
|
4357
|
+
weeklyLoad,
|
|
4358
|
+
activityBreakdown,
|
|
4359
|
+
vitalsTrend,
|
|
4360
|
+
sessionSignals: sessions
|
|
4361
|
+
.slice(-200)
|
|
4362
|
+
.reverse()
|
|
4363
|
+
.map((session) => {
|
|
4364
|
+
const highSeconds = zoneSeconds(session, ["zone_4", "zone_5"]);
|
|
4365
|
+
const totalZoneSeconds = zoneSeconds(session, WORKOUT_ZONE_ORDER);
|
|
4366
|
+
return {
|
|
4367
|
+
id: session.id,
|
|
4368
|
+
dateKey: dayKey(session.startedAt),
|
|
4369
|
+
startedAt: session.startedAt,
|
|
4370
|
+
workoutType: session.workoutType,
|
|
4371
|
+
workoutTypeLabel: session.workoutTypeLabel ?? session.workoutType,
|
|
4372
|
+
durationMinutes: round(session.durationSeconds / 60, 1),
|
|
4373
|
+
trainingLoad: round(workoutLoad(session), 1),
|
|
4374
|
+
intensity: workoutIntensity(session),
|
|
4375
|
+
averageHr: workoutAverageHr(session),
|
|
4376
|
+
maxHr: workoutMaxHr(session),
|
|
4377
|
+
highIntensityPercentage: totalZoneSeconds > 0 ? round(highSeconds / totalZoneSeconds, 3) : 0,
|
|
4378
|
+
highIntensityMinutes: round(highSeconds / 60, 1),
|
|
4379
|
+
heartRateCoverage: workoutHrCoverage(session),
|
|
4380
|
+
heartRateSampleCount: workoutHrSampleCount(session),
|
|
4381
|
+
confidence: session.analytics?.confidence ??
|
|
4382
|
+
"unavailable",
|
|
4383
|
+
detailRoute: `/api/v1/health/workouts/${session.id}/detail`
|
|
4384
|
+
};
|
|
4385
|
+
}),
|
|
4386
|
+
targetModel: {
|
|
4387
|
+
model: "forge-training-load-v1",
|
|
4388
|
+
lowIntensityTarget: "70-85% of total endurance time",
|
|
4389
|
+
moderateIntensityTarget: "5-20% depending on phase and sport specificity",
|
|
4390
|
+
highIntensityTarget: "8-18% unless in a short peaking block",
|
|
4391
|
+
monitoringNotes: [
|
|
4392
|
+
"Use Forge TRIMP as an internal-load trend, not a medical diagnosis.",
|
|
4393
|
+
"Treat kickboxing and sparring as high-intensity days when Z4+Z5 is material.",
|
|
4394
|
+
"Prefer added easy aerobic volume when high-intensity minutes are already high.",
|
|
4395
|
+
"Chest-strap HR is recommended for combat sports when exact zone decisions matter."
|
|
4396
|
+
]
|
|
4397
|
+
}
|
|
4398
|
+
};
|
|
4399
|
+
}
|
|
4065
4400
|
export function getFitnessViewData(userIds) {
|
|
4066
4401
|
const workoutRows = listWorkoutRows(userIds);
|
|
4067
4402
|
const recent = workoutRows
|