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/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-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">
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-PA_Ih223.css">
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>
@@ -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 distribution, weekly intensity targets, and data-quality flags.",
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 intensity targets, and data-quality flags.",
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 and TRIMP-like internal load from stored workout evidence."
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 = finiteNumberFromUnknown(captureQuality.heartRateSamples);
2763
+ const captureHeartRateCount = summaryOnlyExport
2764
+ ? null
2765
+ : finiteNumberFromUnknown(captureQuality.heartRateSamples);
2758
2766
  const syncRoutePointCount = finiteNumberFromUnknown(syncCursor.routePointCount);
2759
- const captureRoutePointCount = finiteNumberFromUnknown(captureQuality.routePoints);
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
- ? actualTimeSeriesCount >= evidenceCounts.expectedTimeSeriesSamples &&
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: summarizeIntensityDistribution(recent28),
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