forge-openclaw-plugin 0.2.94 → 0.2.97
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-D1HbyD4u.js → board-Ju0h0SeG.js} +1 -1
- package/dist/assets/index-Cn5Wpwau.css +1 -0
- package/dist/assets/{index-DWZd3qT-.js → index-CwvGs8n4.js} +55 -55
- package/dist/assets/{motion-D2OqILg_.js → motion-DRPJkN3a.js} +1 -1
- package/dist/assets/{table-YWWjPjC_.js → table-DewbFlTh.js} +1 -1
- package/dist/assets/{ui-DikPZj8S.js → ui-C2IvSrAz.js} +1 -1
- package/dist/assets/{vendor-BS9OPVNh.js → vendor-DL2K5ayT.js} +230 -225
- package/dist/index.html +7 -7
- package/dist/openclaw/tools.js +1 -1
- package/dist/server/server/src/app.js +23 -5
- package/dist/server/server/src/health.js +463 -24
- package/dist/server/server/src/openapi.js +13 -0
- package/dist/server/server/src/web.js +6 -0
- package/dist/server/src/components/ui/info-tooltip.js +7 -3
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/skills/forge-openclaw/SKILL.md +5 -4
- package/skills/forge-openclaw/entity_conversation_playbooks.md +41 -4
- package/dist/assets/index-PA_Ih223.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-CwvGs8n4.js"></script>
|
|
17
|
+
<link rel="modulepreload" crossorigin href="/forge/assets/vendor-DL2K5ayT.js">
|
|
18
|
+
<link rel="modulepreload" crossorigin href="/forge/assets/board-Ju0h0SeG.js">
|
|
19
|
+
<link rel="modulepreload" crossorigin href="/forge/assets/ui-C2IvSrAz.js">
|
|
20
|
+
<link rel="modulepreload" crossorigin href="/forge/assets/motion-DRPJkN3a.js">
|
|
21
|
+
<link rel="modulepreload" crossorigin href="/forge/assets/table-DewbFlTh.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-Cn5Wpwau.css">
|
|
24
24
|
</head>
|
|
25
25
|
<body class="bg-canvas text-ink antialiased">
|
|
26
26
|
<div id="root"></div>
|
package/dist/openclaw/tools.js
CHANGED
|
@@ -755,7 +755,7 @@ export function registerForgePluginTools(api, config) {
|
|
|
755
755
|
registerReadTool(api, config, {
|
|
756
756
|
name: "forge_get_training_load_overview",
|
|
757
757
|
label: "Forge Training Load Overview",
|
|
758
|
-
description: "Read the cardiovascular training-load surface with acute/chronic load, HR zone
|
|
758
|
+
description: "Read the cardiovascular training-load surface with acute/chronic load, HR zone-time buckets, smart training modes, weekly targets, next-workout guidance, and data-quality flags.",
|
|
759
759
|
parameters: scopedReadSchema,
|
|
760
760
|
path: (params) => withUserIds("/api/v1/health/training-load", params.userIds)
|
|
761
761
|
});
|
|
@@ -2247,7 +2247,7 @@ function buildPreferredMutationPath(entityType) {
|
|
|
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
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.";
|
|
2250
|
+
return "Read-only surface. Use it for cardiovascular load, HR zones, zone-time buckets, smart training modes, acute/chronic stress, VO2max context, next-workout guidance, and target analysis; use batch CRUD for underlying workout_session records.";
|
|
2251
2251
|
default:
|
|
2252
2252
|
return "Read-only surface.";
|
|
2253
2253
|
}
|
|
@@ -3044,14 +3044,14 @@ const AGENT_ONBOARDING_ENTITY_CATALOG = [
|
|
|
3044
3044
|
}),
|
|
3045
3045
|
enrichOnboardingEntityGuide({
|
|
3046
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.",
|
|
3047
|
+
purpose: "The read-model cardiovascular training-load workspace for acute/chronic load, HR zone distribution, zone-time buckets, smart training modes, next-workout guidance, intensity targets, and VO2max context.",
|
|
3048
3048
|
minimumCreateFields: [],
|
|
3049
3049
|
relationshipRules: [
|
|
3050
3050
|
"Use this surface for review and interpretation.",
|
|
3051
3051
|
"Create, update, delete, or search the underlying workout_session records through batch CRUD by default."
|
|
3052
3052
|
],
|
|
3053
3053
|
searchHints: [
|
|
3054
|
-
"Read this surface before advising on high-intensity balance, recovery load, or cardiovascular training targets."
|
|
3054
|
+
"Read this surface before advising on high-intensity balance, recovery load, Zone 2/base work, 4x4 suitability, next-workout guidance, or cardiovascular training targets."
|
|
3055
3055
|
],
|
|
3056
3056
|
fieldGuide: []
|
|
3057
3057
|
})
|
|
@@ -3071,6 +3071,7 @@ const AGENT_ONBOARDING_CONVERSATION_RULES = [
|
|
|
3071
3071
|
"Do not over-therapize logistical entities. For tasks, calendar events, work blocks, timeboxes, and task runs, one brief confirming sentence plus one question is usually enough.",
|
|
3072
3072
|
"After each substantive answer, briefly say what is becoming clearer and ask only for the next thing that still changes the record shape or usefulness.",
|
|
3073
3073
|
"For strategic, reflective, or emotionally meaningful non-Psyche records, ask what feels important to keep true before you ask for labels, dates, or taxonomy.",
|
|
3074
|
+
"For reflection-sensitive non-Psyche records such as questionnaire_instrument, questionnaire_run, self_observation, reflective note, wiki_page, sleep_session, workout_session, preference_judgment, and preference_signal, first ask what the reflection should help the user understand, decide, notice, remember, or change later; then keep the API posture exact: batch CRUD for normal stored records, questionnaire run actions for answer lifecycle, self-observation calendar reads plus observed-note writes, and wiki routes for wiki pages.",
|
|
3074
3075
|
"For reusable records such as tags, event types, emotion definitions, preference contexts, or questionnaires, ask what distinction or decision the record should help with before you ask for wording.",
|
|
3075
3076
|
"When useful, help the user name, define, and connect the record in that order: offer a working label, clarify what belongs inside it, then ask about links only after the record itself feels steady.",
|
|
3076
3077
|
"When the meaning is clearer than the wording, offer a tentative title or formulation yourself and invite correction instead of forcing the user to wordsmith alone.",
|
|
@@ -4261,14 +4262,14 @@ const AGENT_ONBOARDING_TOOL_INPUT_CATALOG = [
|
|
|
4261
4262
|
},
|
|
4262
4263
|
{
|
|
4263
4264
|
toolName: "forge_get_training_load_overview",
|
|
4264
|
-
summary: "Read the cardiovascular training-load surface with acute/chronic load, HR zone distribution, weekly
|
|
4265
|
+
summary: "Read the cardiovascular training-load surface with acute/chronic load, HR zone distribution, zone-time buckets, smart training modes, weekly targets, next-workout guidance, and data-quality flags.",
|
|
4265
4266
|
whenToUse: "Use when the operator wants to analyze training stress, zone balance, VO2max context, combat-sport load, or what to optimize next.",
|
|
4266
4267
|
inputShape: "{ userIds?: string[] }",
|
|
4267
4268
|
requiredFields: [],
|
|
4268
4269
|
notes: [
|
|
4269
4270
|
"The API path is /api/v1/health/training-load and the UI route is /training-load.",
|
|
4270
4271
|
"This is a read-model-only surface. Workout records remain ordinary workout_session entities for batch CRUD.",
|
|
4271
|
-
"Forge uses HRR zone analytics
|
|
4272
|
+
"Forge uses HRR zone analytics, TRIMP-like internal load, per-bucket load rate, personal-baseline comparisons, and deterministic training-intelligence modes from stored workout evidence."
|
|
4272
4273
|
],
|
|
4273
4274
|
example: '{"userIds":["user_operator"]}'
|
|
4274
4275
|
},
|
|
@@ -6262,6 +6263,23 @@ function compactTrainingLoad(trainingLoad) {
|
|
|
6262
6263
|
summary: trainingLoad.summary,
|
|
6263
6264
|
intensityDistribution: trainingLoad.recentIntensityDistribution,
|
|
6264
6265
|
weeklyLoad: trainingLoad.weeklyLoad.slice(-8),
|
|
6266
|
+
latestZoneTime: trainingLoad.zoneTimeSeries.weekly.at(-1) ??
|
|
6267
|
+
trainingLoad.zoneTimeSeries.daily.at(-1) ??
|
|
6268
|
+
null,
|
|
6269
|
+
trainingIntelligence: {
|
|
6270
|
+
defaultMode: trainingLoad.trainingIntelligence.defaultMode,
|
|
6271
|
+
modes: trainingLoad.trainingIntelligence.modes.map((mode) => ({
|
|
6272
|
+
key: mode.key,
|
|
6273
|
+
label: mode.label,
|
|
6274
|
+
score: mode.score,
|
|
6275
|
+
status: mode.status,
|
|
6276
|
+
confidence: mode.confidence,
|
|
6277
|
+
summary: mode.summary,
|
|
6278
|
+
loadBalance: mode.loadBalance,
|
|
6279
|
+
nextWorkout: mode.nextWorkout,
|
|
6280
|
+
nextWeekTargets: mode.nextWeekTargets
|
|
6281
|
+
}))
|
|
6282
|
+
},
|
|
6265
6283
|
topActivities: trainingLoad.activityBreakdown.slice(0, 6),
|
|
6266
6284
|
targetModel: trainingLoad.targetModel,
|
|
6267
6285
|
detailRoute: "/api/v1/health/training-load"
|
|
@@ -2750,20 +2750,34 @@ function nestedRecord(value) {
|
|
|
2750
2750
|
? value
|
|
2751
2751
|
: {};
|
|
2752
2752
|
}
|
|
2753
|
+
const CURRENT_WORKOUT_RAW_EVIDENCE_VERSION = "healthkit-workout-raw-bulk-v4";
|
|
2753
2754
|
function expectedWorkoutEvidenceCounts(derived) {
|
|
2754
2755
|
const syncCursor = nestedRecord(derived.syncCursor);
|
|
2755
2756
|
const captureQuality = nestedRecord(derived.captureQuality);
|
|
2757
|
+
const captureQualityFlags = Array.isArray(captureQuality.flags)
|
|
2758
|
+
? captureQuality.flags.filter((flag) => typeof flag === "string")
|
|
2759
|
+
: [];
|
|
2760
|
+
const summaryOnlyExport = captureQuality.status === "summary_exported" ||
|
|
2761
|
+
captureQualityFlags.includes("server_side_evidence_derivation");
|
|
2756
2762
|
const syncTimeSeriesCount = finiteNumberFromUnknown(syncCursor.timeSeriesSampleCount);
|
|
2757
|
-
const captureHeartRateCount =
|
|
2763
|
+
const captureHeartRateCount = summaryOnlyExport
|
|
2764
|
+
? null
|
|
2765
|
+
: finiteNumberFromUnknown(captureQuality.heartRateSamples);
|
|
2758
2766
|
const syncRoutePointCount = finiteNumberFromUnknown(syncCursor.routePointCount);
|
|
2759
|
-
const captureRoutePointCount =
|
|
2767
|
+
const captureRoutePointCount = summaryOnlyExport
|
|
2768
|
+
? null
|
|
2769
|
+
: finiteNumberFromUnknown(captureQuality.routePoints);
|
|
2760
2770
|
const expectedTimeSeriesSamples = Math.max(0, Math.ceil(Math.max(syncTimeSeriesCount ?? 0, captureHeartRateCount ?? 0)));
|
|
2761
2771
|
const expectedHeartRateSamples = Math.max(0, Math.ceil(captureHeartRateCount ?? 0));
|
|
2762
2772
|
const expectedRoutePoints = Math.max(0, Math.ceil(Math.max(syncRoutePointCount ?? 0, captureRoutePointCount ?? 0)));
|
|
2773
|
+
const rawEvidenceVersion = typeof syncCursor.rawEvidenceVersion === "string"
|
|
2774
|
+
? syncCursor.rawEvidenceVersion
|
|
2775
|
+
: "";
|
|
2763
2776
|
return {
|
|
2764
2777
|
expectedTimeSeriesSamples,
|
|
2765
2778
|
expectedHeartRateSamples,
|
|
2766
2779
|
expectedRoutePoints,
|
|
2780
|
+
hasCurrentRawEvidenceVersion: rawEvidenceVersion === CURRENT_WORKOUT_RAW_EVIDENCE_VERSION,
|
|
2767
2781
|
hasEvidenceMetadata: syncTimeSeriesCount !== null ||
|
|
2768
2782
|
captureHeartRateCount !== null ||
|
|
2769
2783
|
syncRoutePointCount !== null ||
|
|
@@ -2816,7 +2830,8 @@ function mobileHealthWorkoutImportState(userId) {
|
|
|
2816
2830
|
const actualHeartRateCount = Math.max(0, row.heart_rate_count ?? 0);
|
|
2817
2831
|
const actualRoutePointCount = Math.max(0, row.route_point_count ?? 0);
|
|
2818
2832
|
const evidenceComplete = evidenceCounts.hasEvidenceMetadata
|
|
2819
|
-
?
|
|
2833
|
+
? evidenceCounts.hasCurrentRawEvidenceVersion &&
|
|
2834
|
+
actualTimeSeriesCount >= evidenceCounts.expectedTimeSeriesSamples &&
|
|
2820
2835
|
actualHeartRateCount >= evidenceCounts.expectedHeartRateSamples &&
|
|
2821
2836
|
actualRoutePointCount >= evidenceCounts.expectedRoutePoints
|
|
2822
2837
|
: false;
|
|
@@ -4108,6 +4123,176 @@ function zoneSeconds(session, keys) {
|
|
|
4108
4123
|
.filter((zone) => keys.includes(zone.key))
|
|
4109
4124
|
.reduce((sum, zone) => sum + zone.seconds, 0);
|
|
4110
4125
|
}
|
|
4126
|
+
const TRAINING_DOMAIN_KEYS = ["low", "moderate", "high"];
|
|
4127
|
+
function clampNumber(value, min, max) {
|
|
4128
|
+
return Math.max(min, Math.min(max, value));
|
|
4129
|
+
}
|
|
4130
|
+
function createZoneRecord() {
|
|
4131
|
+
return Object.fromEntries(WORKOUT_ZONE_ORDER.map((key) => [key, 0]));
|
|
4132
|
+
}
|
|
4133
|
+
function createDomainRecord() {
|
|
4134
|
+
return { low: 0, moderate: 0, high: 0 };
|
|
4135
|
+
}
|
|
4136
|
+
function addSessionZones(target, session) {
|
|
4137
|
+
const zones = session.analytics
|
|
4138
|
+
?.zoneDurations ?? [];
|
|
4139
|
+
for (const zone of zones) {
|
|
4140
|
+
if (WORKOUT_ZONE_ORDER.includes(zone.key)) {
|
|
4141
|
+
target[zone.key] += zone.seconds;
|
|
4142
|
+
}
|
|
4143
|
+
}
|
|
4144
|
+
}
|
|
4145
|
+
function zoneRecordTotal(record) {
|
|
4146
|
+
return WORKOUT_ZONE_ORDER.reduce((sum, key) => sum + record[key], 0);
|
|
4147
|
+
}
|
|
4148
|
+
function domainSecondsFromZones(zones) {
|
|
4149
|
+
return {
|
|
4150
|
+
low: zones.below_z1 + zones.zone_1,
|
|
4151
|
+
moderate: zones.zone_2 + zones.zone_3,
|
|
4152
|
+
high: zones.zone_4 + zones.zone_5
|
|
4153
|
+
};
|
|
4154
|
+
}
|
|
4155
|
+
function percentageRecord(record, total) {
|
|
4156
|
+
return Object.fromEntries(Object.entries(record).map(([key, value]) => [
|
|
4157
|
+
key,
|
|
4158
|
+
total > 0 ? round(value / total, 4) : 0
|
|
4159
|
+
]));
|
|
4160
|
+
}
|
|
4161
|
+
function minuteRecord(record) {
|
|
4162
|
+
return Object.fromEntries(Object.entries(record).map(([key, value]) => [key, round(value / 60, 1)]));
|
|
4163
|
+
}
|
|
4164
|
+
function bucketConfidence(input) {
|
|
4165
|
+
if (input.sessionCount <= 0 || input.heartRateSampleCount <= 0) {
|
|
4166
|
+
return "unavailable";
|
|
4167
|
+
}
|
|
4168
|
+
if (input.averageHrCoverage >= 0.8 &&
|
|
4169
|
+
input.heartRateSampleCount >= input.sessionCount * 100) {
|
|
4170
|
+
return "high";
|
|
4171
|
+
}
|
|
4172
|
+
if (input.averageHrCoverage >= 0.45) {
|
|
4173
|
+
return "medium";
|
|
4174
|
+
}
|
|
4175
|
+
return "low";
|
|
4176
|
+
}
|
|
4177
|
+
function monthKey(value) {
|
|
4178
|
+
return value.slice(0, 7);
|
|
4179
|
+
}
|
|
4180
|
+
function monthEndDateKey(key) {
|
|
4181
|
+
const [year, month] = key.split("-").map((part) => Number(part));
|
|
4182
|
+
return dateKeyFromDate(new Date(Date.UTC(year, month, 0)));
|
|
4183
|
+
}
|
|
4184
|
+
function bucketDates(interval, session) {
|
|
4185
|
+
if (interval === "daily") {
|
|
4186
|
+
const key = dayKey(session.startedAt);
|
|
4187
|
+
return { bucketKey: key, startDate: key, endDate: key };
|
|
4188
|
+
}
|
|
4189
|
+
if (interval === "weekly") {
|
|
4190
|
+
const weekKey = isoWeekKey(session.startedAt);
|
|
4191
|
+
const date = new Date(session.startedAt);
|
|
4192
|
+
const day = date.getUTCDay() || 7;
|
|
4193
|
+
const start = addDays(date, 1 - day);
|
|
4194
|
+
const end = addDays(start, 6);
|
|
4195
|
+
return {
|
|
4196
|
+
bucketKey: weekKey,
|
|
4197
|
+
startDate: dateKeyFromDate(start),
|
|
4198
|
+
endDate: dateKeyFromDate(end)
|
|
4199
|
+
};
|
|
4200
|
+
}
|
|
4201
|
+
const key = monthKey(session.startedAt);
|
|
4202
|
+
return {
|
|
4203
|
+
bucketKey: key,
|
|
4204
|
+
startDate: `${key}-01`,
|
|
4205
|
+
endDate: monthEndDateKey(key)
|
|
4206
|
+
};
|
|
4207
|
+
}
|
|
4208
|
+
function buildZoneTimeBuckets(sessions, interval, limit) {
|
|
4209
|
+
const buckets = new Map();
|
|
4210
|
+
for (const session of sessions) {
|
|
4211
|
+
const dates = bucketDates(interval, session);
|
|
4212
|
+
const current = buckets.get(dates.bucketKey) ?? {
|
|
4213
|
+
...dates,
|
|
4214
|
+
sessionCount: 0,
|
|
4215
|
+
durationSeconds: 0,
|
|
4216
|
+
trainingLoad: 0,
|
|
4217
|
+
zoneSeconds: createZoneRecord(),
|
|
4218
|
+
dayHighSeconds: new Map(),
|
|
4219
|
+
heartRateCoverageValues: [],
|
|
4220
|
+
heartRateSampleCount: 0
|
|
4221
|
+
};
|
|
4222
|
+
current.sessionCount += 1;
|
|
4223
|
+
current.durationSeconds += session.durationSeconds;
|
|
4224
|
+
current.trainingLoad += workoutLoad(session);
|
|
4225
|
+
addSessionZones(current.zoneSeconds, session);
|
|
4226
|
+
const sessionDayKey = dayKey(session.startedAt);
|
|
4227
|
+
current.dayHighSeconds.set(sessionDayKey, (current.dayHighSeconds.get(sessionDayKey) ?? 0) +
|
|
4228
|
+
zoneSeconds(session, ["zone_4", "zone_5"]));
|
|
4229
|
+
current.heartRateCoverageValues.push(workoutHrCoverage(session));
|
|
4230
|
+
current.heartRateSampleCount += workoutHrSampleCount(session);
|
|
4231
|
+
buckets.set(dates.bucketKey, current);
|
|
4232
|
+
}
|
|
4233
|
+
const rows = [...buckets.values()]
|
|
4234
|
+
.sort((left, right) => left.bucketKey.localeCompare(right.bucketKey))
|
|
4235
|
+
.slice(-limit)
|
|
4236
|
+
.map((bucket) => {
|
|
4237
|
+
const hrCoveredSeconds = zoneRecordTotal(bucket.zoneSeconds);
|
|
4238
|
+
const domains = domainSecondsFromZones(bucket.zoneSeconds);
|
|
4239
|
+
const durationMinutes = bucket.durationSeconds / 60;
|
|
4240
|
+
const loadPerMinute = durationMinutes > 0 ? round(bucket.trainingLoad / durationMinutes, 2) : 0;
|
|
4241
|
+
const loadPerHour = bucket.durationSeconds > 0
|
|
4242
|
+
? round(bucket.trainingLoad / (bucket.durationSeconds / 3600), 1)
|
|
4243
|
+
: 0;
|
|
4244
|
+
const averageHrCoverage = round(average(bucket.heartRateCoverageValues), 3);
|
|
4245
|
+
return {
|
|
4246
|
+
bucketKey: bucket.bucketKey,
|
|
4247
|
+
startDate: bucket.startDate,
|
|
4248
|
+
endDate: bucket.endDate,
|
|
4249
|
+
sessionCount: bucket.sessionCount,
|
|
4250
|
+
durationSeconds: Math.round(bucket.durationSeconds),
|
|
4251
|
+
durationMinutes: round(bucket.durationSeconds / 60, 1),
|
|
4252
|
+
hrCoveredSeconds: Math.round(hrCoveredSeconds),
|
|
4253
|
+
trainingLoad: round(bucket.trainingLoad, 1),
|
|
4254
|
+
loadPerHour,
|
|
4255
|
+
loadPerMinute,
|
|
4256
|
+
baselineLoadRatio: null,
|
|
4257
|
+
baselineIntensityRatio: null,
|
|
4258
|
+
zoneSeconds: Object.fromEntries(WORKOUT_ZONE_ORDER.map((key) => [key, Math.round(bucket.zoneSeconds[key])])),
|
|
4259
|
+
zoneMinutes: minuteRecord(bucket.zoneSeconds),
|
|
4260
|
+
zonePercentages: percentageRecord(bucket.zoneSeconds, hrCoveredSeconds),
|
|
4261
|
+
domainSeconds: Object.fromEntries(TRAINING_DOMAIN_KEYS.map((key) => [key, Math.round(domains[key])])),
|
|
4262
|
+
domainMinutes: minuteRecord(domains),
|
|
4263
|
+
domainPercentages: percentageRecord(domains, hrCoveredSeconds),
|
|
4264
|
+
hardDayCount: [...bucket.dayHighSeconds.values()].filter((seconds) => seconds >= 10 * 60).length,
|
|
4265
|
+
averageHrCoverage,
|
|
4266
|
+
heartRateSampleCount: bucket.heartRateSampleCount,
|
|
4267
|
+
confidence: bucketConfidence({
|
|
4268
|
+
sessionCount: bucket.sessionCount,
|
|
4269
|
+
averageHrCoverage,
|
|
4270
|
+
heartRateSampleCount: bucket.heartRateSampleCount
|
|
4271
|
+
})
|
|
4272
|
+
};
|
|
4273
|
+
});
|
|
4274
|
+
return rows.map((row, index) => {
|
|
4275
|
+
const baselineWindow = interval === "daily"
|
|
4276
|
+
? rows.slice(Math.max(0, index - 28), index)
|
|
4277
|
+
: interval === "weekly"
|
|
4278
|
+
? rows.slice(Math.max(0, index - 4), index)
|
|
4279
|
+
: rows.slice(Math.max(0, index - 3), index);
|
|
4280
|
+
const baselineLoad = average(baselineWindow.map((bucket) => bucket.trainingLoad).filter((value) => value > 0));
|
|
4281
|
+
const baselineIntensity = average(baselineWindow.map((bucket) => bucket.loadPerMinute).filter((value) => value > 0));
|
|
4282
|
+
return {
|
|
4283
|
+
...row,
|
|
4284
|
+
baselineLoadRatio: baselineLoad > 0 ? round(row.trainingLoad / baselineLoad, 2) : null,
|
|
4285
|
+
baselineIntensityRatio: baselineIntensity > 0 ? round(row.loadPerMinute / baselineIntensity, 2) : null
|
|
4286
|
+
};
|
|
4287
|
+
});
|
|
4288
|
+
}
|
|
4289
|
+
function buildZoneTimeSeries(sessions) {
|
|
4290
|
+
return {
|
|
4291
|
+
daily: buildZoneTimeBuckets(sessions, "daily", 90),
|
|
4292
|
+
weekly: buildZoneTimeBuckets(sessions, "weekly", 26),
|
|
4293
|
+
monthly: buildZoneTimeBuckets(sessions, "monthly", 12)
|
|
4294
|
+
};
|
|
4295
|
+
}
|
|
4111
4296
|
function workoutLoad(session) {
|
|
4112
4297
|
return (session.analytics?.load
|
|
4113
4298
|
?.trimp ?? 0);
|
|
@@ -4186,6 +4371,251 @@ function summarizeIntensityDistribution(sessions) {
|
|
|
4186
4371
|
function latestVitalValue(vitalsTrend, key) {
|
|
4187
4372
|
return [...vitalsTrend].reverse().find((entry) => entry[key] != null)?.[key] ?? null;
|
|
4188
4373
|
}
|
|
4374
|
+
const MODE_DOMAIN_TARGETS = {
|
|
4375
|
+
combat_readiness: {
|
|
4376
|
+
label: "Combat readiness",
|
|
4377
|
+
statusLabel: "fight-ready balance",
|
|
4378
|
+
low: [0.62, 0.78],
|
|
4379
|
+
moderate: [0.1, 0.22],
|
|
4380
|
+
high: [0.06, 0.16],
|
|
4381
|
+
maxHardSessions: 2
|
|
4382
|
+
},
|
|
4383
|
+
aerobic_base: {
|
|
4384
|
+
label: "Aerobic base",
|
|
4385
|
+
statusLabel: "base-building balance",
|
|
4386
|
+
low: [0.72, 0.9],
|
|
4387
|
+
moderate: [0.05, 0.18],
|
|
4388
|
+
high: [0.02, 0.1],
|
|
4389
|
+
maxHardSessions: 1
|
|
4390
|
+
},
|
|
4391
|
+
endurance_pro: {
|
|
4392
|
+
label: "Endurance pro",
|
|
4393
|
+
statusLabel: "pyramidal/polarized balance",
|
|
4394
|
+
low: [0.7, 0.88],
|
|
4395
|
+
moderate: [0.04, 0.2],
|
|
4396
|
+
high: [0.06, 0.18],
|
|
4397
|
+
maxHardSessions: 2
|
|
4398
|
+
}
|
|
4399
|
+
};
|
|
4400
|
+
function targetMidpoint(range) {
|
|
4401
|
+
return (range[0] + range[1]) / 2;
|
|
4402
|
+
}
|
|
4403
|
+
function rangeScore(value, range) {
|
|
4404
|
+
if (value >= range[0] && value <= range[1]) {
|
|
4405
|
+
return 100;
|
|
4406
|
+
}
|
|
4407
|
+
const distance = value < range[0] ? range[0] - value : value - range[1];
|
|
4408
|
+
return clampNumber(100 - distance * 350, 0, 100);
|
|
4409
|
+
}
|
|
4410
|
+
function loadBalanceStatus(summary) {
|
|
4411
|
+
if (summary.readiness === "overload_watch" ||
|
|
4412
|
+
(summary.acuteChronicRatio ?? 0) > 1.35 ||
|
|
4413
|
+
(summary.strain7d ?? 0) > 450) {
|
|
4414
|
+
return "recover";
|
|
4415
|
+
}
|
|
4416
|
+
if ((summary.acuteChronicRatio ?? 1) < 0.8) {
|
|
4417
|
+
return "build";
|
|
4418
|
+
}
|
|
4419
|
+
if (summary.highIntensityMinutes7d >= 45) {
|
|
4420
|
+
return "maintain";
|
|
4421
|
+
}
|
|
4422
|
+
return "sharpen";
|
|
4423
|
+
}
|
|
4424
|
+
function scoreStatus(score, balance) {
|
|
4425
|
+
if (balance === "recover") {
|
|
4426
|
+
return "recovery_priority";
|
|
4427
|
+
}
|
|
4428
|
+
if (score >= 82) {
|
|
4429
|
+
return "on_target";
|
|
4430
|
+
}
|
|
4431
|
+
if (score >= 65) {
|
|
4432
|
+
return "watch";
|
|
4433
|
+
}
|
|
4434
|
+
return "needs_adjustment";
|
|
4435
|
+
}
|
|
4436
|
+
function scoreConfidence(summary, latestBucket) {
|
|
4437
|
+
const coverage = latestBucket?.averageHrCoverage ?? summary.averageHeartRateCoverage;
|
|
4438
|
+
if (coverage >= 0.85 && (latestBucket?.heartRateSampleCount ?? 0) >= 300) {
|
|
4439
|
+
return "high";
|
|
4440
|
+
}
|
|
4441
|
+
if (coverage >= 0.5) {
|
|
4442
|
+
return "medium";
|
|
4443
|
+
}
|
|
4444
|
+
if (coverage > 0) {
|
|
4445
|
+
return "low";
|
|
4446
|
+
}
|
|
4447
|
+
return "unavailable";
|
|
4448
|
+
}
|
|
4449
|
+
function targetMinutes(totalMinutes, range) {
|
|
4450
|
+
return [Math.round(totalMinutes * range[0]), Math.round(totalMinutes * range[1])];
|
|
4451
|
+
}
|
|
4452
|
+
function buildZoneMinuteTargets(totalMinutes, target) {
|
|
4453
|
+
const low = totalMinutes * targetMidpoint(target.low);
|
|
4454
|
+
const moderate = totalMinutes * targetMidpoint(target.moderate);
|
|
4455
|
+
const high = totalMinutes * targetMidpoint(target.high);
|
|
4456
|
+
return {
|
|
4457
|
+
below_z1: Math.round(low * 0.35),
|
|
4458
|
+
zone_1: Math.round(low * 0.65),
|
|
4459
|
+
zone_2: Math.round(moderate * 0.65),
|
|
4460
|
+
zone_3: Math.round(moderate * 0.35),
|
|
4461
|
+
zone_4: Math.round(high * 0.75),
|
|
4462
|
+
zone_5: Math.round(high * 0.25)
|
|
4463
|
+
};
|
|
4464
|
+
}
|
|
4465
|
+
function buildTrainingIntelligence(input) {
|
|
4466
|
+
const latestWeek = input.zoneTimeSeries.weekly.at(-1);
|
|
4467
|
+
const priorWeeks = input.zoneTimeSeries.weekly.slice(-5, -1);
|
|
4468
|
+
const baselineMinutes = average(priorWeeks.map((week) => week.durationMinutes).filter((value) => value > 0)) ||
|
|
4469
|
+
latestWeek?.durationMinutes ||
|
|
4470
|
+
180;
|
|
4471
|
+
const balance = loadBalanceStatus(input.summary);
|
|
4472
|
+
const lowPct = input.recentIntensityDistribution.find((entry) => entry.key === "low")
|
|
4473
|
+
?.percentage ?? 0;
|
|
4474
|
+
const moderatePct = input.recentIntensityDistribution.find((entry) => entry.key === "moderate")
|
|
4475
|
+
?.percentage ?? 0;
|
|
4476
|
+
const highPct = input.recentIntensityDistribution.find((entry) => entry.key === "high")
|
|
4477
|
+
?.percentage ?? 0;
|
|
4478
|
+
const currentLoadRatio = input.summary.acuteChronicRatio ?? 1;
|
|
4479
|
+
const loadBalance = {
|
|
4480
|
+
status: balance,
|
|
4481
|
+
acuteLoad7d: input.summary.acuteLoad7d,
|
|
4482
|
+
chronicWeeklyLoad28d: input.summary.chronicWeeklyLoad28d,
|
|
4483
|
+
acuteChronicRatio: input.summary.acuteChronicRatio,
|
|
4484
|
+
monotony7d: input.summary.monotony7d,
|
|
4485
|
+
strain7d: input.summary.strain7d,
|
|
4486
|
+
latestWeekLoad: latestWeek?.trainingLoad ?? 0,
|
|
4487
|
+
latestWeekLoadPerMinute: latestWeek?.loadPerMinute ?? 0,
|
|
4488
|
+
latestWeekBaselineLoadRatio: latestWeek?.baselineLoadRatio ?? null,
|
|
4489
|
+
latestWeekBaselineIntensityRatio: latestWeek?.baselineIntensityRatio ?? null
|
|
4490
|
+
};
|
|
4491
|
+
return {
|
|
4492
|
+
defaultMode: "combat_readiness",
|
|
4493
|
+
modes: Object.keys(MODE_DOMAIN_TARGETS).map((mode) => {
|
|
4494
|
+
const target = MODE_DOMAIN_TARGETS[mode];
|
|
4495
|
+
const lowScore = rangeScore(lowPct, target.low);
|
|
4496
|
+
const moderateScore = rangeScore(moderatePct, target.moderate);
|
|
4497
|
+
const highScore = rangeScore(highPct, target.high);
|
|
4498
|
+
const loadScore = balance === "recover"
|
|
4499
|
+
? 45
|
|
4500
|
+
: clampNumber(100 - Math.abs(currentLoadRatio - 1) * 90, 0, 100);
|
|
4501
|
+
const hardDayScore = clampNumber(100 - Math.max(0, input.summary.hardDayCount7d - target.maxHardSessions) * 28, 0, 100);
|
|
4502
|
+
const qualityScore = clampNumber(input.summary.averageHeartRateCoverage * 100, 0, 100);
|
|
4503
|
+
const score = Math.round(lowScore * 0.22 +
|
|
4504
|
+
moderateScore * 0.14 +
|
|
4505
|
+
highScore * 0.2 +
|
|
4506
|
+
loadScore * 0.24 +
|
|
4507
|
+
hardDayScore * 0.12 +
|
|
4508
|
+
qualityScore * 0.08);
|
|
4509
|
+
const nextWeekScale = balance === "recover" ? 0.75 : balance === "build" ? 1.12 : 1;
|
|
4510
|
+
const nextWeekMinutes = Math.round(baselineMinutes * nextWeekScale);
|
|
4511
|
+
const shouldAllowFourByFour = balance !== "recover" &&
|
|
4512
|
+
highPct <= target.high[1] &&
|
|
4513
|
+
input.summary.hardDayCount7d < target.maxHardSessions &&
|
|
4514
|
+
scoreConfidence(input.summary, latestWeek) !== "low" &&
|
|
4515
|
+
scoreConfidence(input.summary, latestWeek) !== "unavailable";
|
|
4516
|
+
const drivers = [
|
|
4517
|
+
lowPct >= target.low[0]
|
|
4518
|
+
? `Low/base work is ${Math.round(lowPct * 100)}% of recent HR-zone time.`
|
|
4519
|
+
: null,
|
|
4520
|
+
highPct <= target.high[1]
|
|
4521
|
+
? `High-domain exposure is controlled at ${Math.round(highPct * 100)}%.`
|
|
4522
|
+
: null,
|
|
4523
|
+
currentLoadRatio >= 0.8 && currentLoadRatio <= 1.25
|
|
4524
|
+
? `Acute load is close to chronic base at ${round(currentLoadRatio, 2)}x.`
|
|
4525
|
+
: null,
|
|
4526
|
+
latestWeek?.baselineLoadRatio != null
|
|
4527
|
+
? `Latest week is ${latestWeek.baselineLoadRatio}x your recent weekly load baseline.`
|
|
4528
|
+
: null
|
|
4529
|
+
].filter((entry) => Boolean(entry));
|
|
4530
|
+
const limitingFactors = [
|
|
4531
|
+
lowPct < target.low[0]
|
|
4532
|
+
? `Low/base work is below the ${Math.round(target.low[0] * 100)}-${Math.round(target.low[1] * 100)}% target band.`
|
|
4533
|
+
: null,
|
|
4534
|
+
highPct > target.high[1]
|
|
4535
|
+
? `High-domain work is above the ${Math.round(target.high[1] * 100)}% ceiling for this mode.`
|
|
4536
|
+
: null,
|
|
4537
|
+
balance === "recover"
|
|
4538
|
+
? "Recent load balance points to recovery before another hard stimulus."
|
|
4539
|
+
: null,
|
|
4540
|
+
input.summary.hardDayCount7d > target.maxHardSessions
|
|
4541
|
+
? `${input.summary.hardDayCount7d} hard days in 7 days exceeds this mode's target.`
|
|
4542
|
+
: null,
|
|
4543
|
+
input.summary.averageHeartRateCoverage < 0.5
|
|
4544
|
+
? "Heart-rate evidence is thin, so zone decisions need caution."
|
|
4545
|
+
: null
|
|
4546
|
+
].filter((entry) => Boolean(entry));
|
|
4547
|
+
return {
|
|
4548
|
+
key: mode,
|
|
4549
|
+
label: target.label,
|
|
4550
|
+
score,
|
|
4551
|
+
status: scoreStatus(score, balance),
|
|
4552
|
+
confidence: scoreConfidence(input.summary, latestWeek),
|
|
4553
|
+
summary: balance === "recover"
|
|
4554
|
+
? `${target.label}: recover first, then rebuild the next hard stimulus.`
|
|
4555
|
+
: `${target.label}: ${target.statusLabel} is ${score >= 75 ? "mostly aligned" : "not yet aligned"}.`,
|
|
4556
|
+
drivers,
|
|
4557
|
+
limitingFactors,
|
|
4558
|
+
loadBalance,
|
|
4559
|
+
nextWeekTargets: {
|
|
4560
|
+
totalMinutesRange: balance === "recover"
|
|
4561
|
+
? targetMinutes(baselineMinutes, [0.65, 0.85])
|
|
4562
|
+
: targetMinutes(nextWeekMinutes, [0.95, 1.08]),
|
|
4563
|
+
zoneMinuteTargets: buildZoneMinuteTargets(nextWeekMinutes, target),
|
|
4564
|
+
domainMinuteTargets: {
|
|
4565
|
+
low: targetMinutes(nextWeekMinutes, target.low),
|
|
4566
|
+
moderate: targetMinutes(nextWeekMinutes, target.moderate),
|
|
4567
|
+
high: targetMinutes(nextWeekMinutes, target.high)
|
|
4568
|
+
},
|
|
4569
|
+
maxHardSessions: balance === "recover" ? Math.max(0, target.maxHardSessions - 1) : target.maxHardSessions,
|
|
4570
|
+
minimumEasyMinutes: Math.round(nextWeekMinutes * target.low[0]),
|
|
4571
|
+
warning: balance === "recover"
|
|
4572
|
+
? "Keep next week below baseline and avoid stacking hard sessions until load balance normalizes."
|
|
4573
|
+
: highPct > target.high[1]
|
|
4574
|
+
? "Do not add another high-intensity session until the high-domain share drops."
|
|
4575
|
+
: null
|
|
4576
|
+
},
|
|
4577
|
+
nextWorkout: {
|
|
4578
|
+
recommendedType: balance === "recover"
|
|
4579
|
+
? "recovery"
|
|
4580
|
+
: lowPct < target.low[0]
|
|
4581
|
+
? "zone_2_base"
|
|
4582
|
+
: shouldAllowFourByFour
|
|
4583
|
+
? "vo2max_4x4"
|
|
4584
|
+
: mode === "combat_readiness"
|
|
4585
|
+
? "technical_kickboxing"
|
|
4586
|
+
: "easy_aerobic",
|
|
4587
|
+
intensityCeiling: balance === "recover"
|
|
4588
|
+
? "Z1"
|
|
4589
|
+
: shouldAllowFourByFour
|
|
4590
|
+
? "Z5 intervals with full recovery"
|
|
4591
|
+
: highPct > target.high[1]
|
|
4592
|
+
? "Z2"
|
|
4593
|
+
: "Z3",
|
|
4594
|
+
durationMinutesRange: balance === "recover"
|
|
4595
|
+
? [20, 40]
|
|
4596
|
+
: lowPct < target.low[0]
|
|
4597
|
+
? [45, 75]
|
|
4598
|
+
: shouldAllowFourByFour
|
|
4599
|
+
? [32, 48]
|
|
4600
|
+
: [35, 60],
|
|
4601
|
+
fourByFourAppropriate: shouldAllowFourByFour,
|
|
4602
|
+
reason: shouldAllowFourByFour
|
|
4603
|
+
? "Load balance, hard-day count, high-domain share, and HR confidence can support one controlled VO2max stimulus."
|
|
4604
|
+
: balance === "recover"
|
|
4605
|
+
? "Current load balance makes recovery more useful than another hard interval day."
|
|
4606
|
+
: highPct > target.high[1]
|
|
4607
|
+
? "Recent high-domain exposure is already above this mode's target."
|
|
4608
|
+
: "A base or technical session fits the current distribution better than 4x4 work."
|
|
4609
|
+
},
|
|
4610
|
+
methodologyNotes: [
|
|
4611
|
+
"Scores are deterministic summaries of Forge HRR zones, TRIMP load, hard-day spacing, and evidence quality.",
|
|
4612
|
+
"ACWR, monotony, and strain are monitoring flags, not injury predictions.",
|
|
4613
|
+
"4x4 guidance is gated by freshness, recent high-intensity share, hard-day count, and HR confidence."
|
|
4614
|
+
]
|
|
4615
|
+
};
|
|
4616
|
+
})
|
|
4617
|
+
};
|
|
4618
|
+
}
|
|
4189
4619
|
export function getTrainingLoadViewData(userIds) {
|
|
4190
4620
|
const workoutRows = listWorkoutRows(userIds);
|
|
4191
4621
|
const sessions = workoutRows
|
|
@@ -4328,33 +4758,42 @@ export function getTrainingLoadViewData(userIds) {
|
|
|
4328
4758
|
: acwr < 0.75
|
|
4329
4759
|
? "underloaded"
|
|
4330
4760
|
: "productive";
|
|
4761
|
+
const summary = {
|
|
4762
|
+
sessionCount: sessions.length,
|
|
4763
|
+
reliableSessionCount: reliableSessions.length,
|
|
4764
|
+
totalHours: round(sessions.reduce((sum, session) => sum + session.durationSeconds, 0) / 3600, 1),
|
|
4765
|
+
totalTrainingLoad: round(sessions.reduce((sum, session) => sum + workoutLoad(session), 0), 1),
|
|
4766
|
+
acuteLoad7d: round(acuteLoad7d, 1),
|
|
4767
|
+
chronicWeeklyLoad28d: round(chronicWeeklyLoad28d, 1),
|
|
4768
|
+
acuteChronicRatio: acwr,
|
|
4769
|
+
monotony7d: monotony7d != null ? round(monotony7d, 2) : null,
|
|
4770
|
+
strain7d: strain7d != null ? round(strain7d, 1) : null,
|
|
4771
|
+
highIntensityMinutes7d: round(highSeconds7d / 60, 1),
|
|
4772
|
+
thresholdMinutes7d: round(moderateSeconds7d / 60, 1),
|
|
4773
|
+
easyMinutes7d: round(lowSeconds7d / 60, 1),
|
|
4774
|
+
hardDayCount7d: last7Keys.filter((key) => (dailyMap.get(key)?.highIntensitySeconds ?? 0) >= 10 * 60).length,
|
|
4775
|
+
averageHeartRateCoverage: round(average(sessions.map((session) => workoutHrCoverage(session))), 3),
|
|
4776
|
+
vo2MaxLatest,
|
|
4777
|
+
vo2MaxDelta,
|
|
4778
|
+
latestRestingHeartRate: latestVitalValue(vitalsTrend, "restingHeartRate"),
|
|
4779
|
+
readiness
|
|
4780
|
+
};
|
|
4781
|
+
const recentIntensityDistribution = summarizeIntensityDistribution(recent28);
|
|
4782
|
+
const zoneTimeSeries = buildZoneTimeSeries(sessions);
|
|
4331
4783
|
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
|
-
},
|
|
4784
|
+
summary,
|
|
4352
4785
|
zoneTotals: summarizeZoneDistribution(sessions),
|
|
4353
4786
|
recentZoneTotals: summarizeZoneDistribution(recent28),
|
|
4354
4787
|
intensityDistribution: summarizeIntensityDistribution(sessions),
|
|
4355
|
-
recentIntensityDistribution
|
|
4788
|
+
recentIntensityDistribution,
|
|
4356
4789
|
dailyLoad: dailyLoad.slice(-90),
|
|
4357
4790
|
weeklyLoad,
|
|
4791
|
+
zoneTimeSeries,
|
|
4792
|
+
trainingIntelligence: buildTrainingIntelligence({
|
|
4793
|
+
summary,
|
|
4794
|
+
recentIntensityDistribution,
|
|
4795
|
+
zoneTimeSeries
|
|
4796
|
+
}),
|
|
4358
4797
|
activityBreakdown,
|
|
4359
4798
|
vitalsTrend,
|
|
4360
4799
|
sessionSignals: sessions
|