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/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-BNvUaA6y.js"></script>
17
- <link rel="modulepreload" crossorigin href="/forge/assets/vendor-BVU0cZC9.js">
18
- <link rel="modulepreload" crossorigin href="/forge/assets/board-DKxKOwax.js">
19
- <link rel="modulepreload" crossorigin href="/forge/assets/ui-3Wd4pVaA.js">
20
- <link rel="modulepreload" crossorigin href="/forge/assets/motion-CM4AfIqo.js">
21
- <link rel="modulepreload" crossorigin href="/forge/assets/table-BUeQ9wzR.js">
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-NqIbz_lv.css">
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>
@@ -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" },
@@ -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",
@@ -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 sports_overview, use the dedicated overview reads first when the user wants review, pattern interpretation, recovery context, or training-load context. Move to sleep_session or workout_session writes only after one specific stored session needs enrichment.",
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
- : actualTimeSeriesCount + actualRoutePointCount > 0;
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