catence 0.2.1-beta.4 → 0.2.1-beta.5

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.
@@ -3,7 +3,7 @@
3
3
  * Keep these values synchronized with release/manifest.json; release checks
4
4
  * deliberately reject drift before a package can be published.
5
5
  */
6
- export declare const CATENCE_RUNTIME_VERSION = "0.2.1-beta.4";
6
+ export declare const CATENCE_RUNTIME_VERSION = "0.2.1-beta.5";
7
7
  export declare const CATENCE_PROTOCOL_VERSION = 1;
8
8
  export type CatenceRuntimeCapabilities = {
9
9
  mcp: true;
@@ -3,7 +3,7 @@
3
3
  * Keep these values synchronized with release/manifest.json; release checks
4
4
  * deliberately reject drift before a package can be published.
5
5
  */
6
- export const CATENCE_RUNTIME_VERSION = '0.2.1-beta.4';
6
+ export const CATENCE_RUNTIME_VERSION = '0.2.1-beta.5';
7
7
  export const CATENCE_PROTOCOL_VERSION = 1;
8
8
  export function catenceRuntimeHealth() {
9
9
  return {
@@ -271,6 +271,8 @@ export async function importSourceEntity(database, entity) {
271
271
  }
272
272
  if (entity.entityType === 'wellness' || entity.entityType === 'daily_health')
273
273
  await importDailyMetrics(database, entity.provider, entity.remoteId, entity.occurredOn, payload, rawHash);
274
+ if (entity.provider === 'garmin' && entity.entityType === 'body_composition')
275
+ await importGarminWeight(database, entity.occurredOn, payload, rawHash);
274
276
  if (entity.entityType === 'nutrition_day' || entity.entityType === 'nutrition_log')
275
277
  await importNutrition(database, entity.provider, entity.occurredOn, entity.remoteId, payload, rawHash);
276
278
  if (entity.provider === 'garmin' && entity.entityType === 'training_metric')
@@ -968,6 +970,64 @@ async function importDailyMetrics(database, provider, remoteId, date, payload, r
968
970
  if (provider === 'garmin')
969
971
  await importGarminDailyDetails(database, remoteId, metricDate, payload, rawHash);
970
972
  }
973
+ /**
974
+ * Weight-only projection for Garmin weigh-ins. The range endpoint returns
975
+ * `{dailyWeightSummaries: [{summaryDate, allWeightMetrics: [{weight, ...}]}]}`
976
+ * with weight in grams, while dayview returns `{dateWeightList: [...]}`.
977
+ * The worker stages a whole range window as one `body_composition` entity,
978
+ * so dates are derived per entry here. Multiple weigh-ins on one day resolve
979
+ * to the latest timestamp. Body-composition fields (fat, muscle, ...) are
980
+ * deliberately ignored.
981
+ */
982
+ function weightKgFromValue(value) {
983
+ if (value === null)
984
+ return null;
985
+ return value > 1000 ? value / 1000 : value;
986
+ }
987
+ function weightEntryDate(entry, fallbackDay) {
988
+ // Prefer the parent summaryDate (Garmin's local day) over the entry's UTC
989
+ // timestampGMT, which can fall on a different UTC date.
990
+ return fallbackDay
991
+ ?? datePart(firstString(entry, ['summaryDate', 'calendarDate', 'date']))
992
+ ?? datePart(timestamp(entry.timestampGMT));
993
+ }
994
+ function collectGarminWeightEntries(payload, fallbackDay) {
995
+ const entries = [];
996
+ const pushEntry = (entry, fallback) => {
997
+ const kg = weightKgFromValue(firstNumber(entry, ['weight', 'weightValue', 'weightInKg']));
998
+ if (kg === null)
999
+ return;
1000
+ const day = weightEntryDate(entry, fallback ?? fallbackDay);
1001
+ if (!day)
1002
+ return;
1003
+ const ts = typeof entry.timestampGMT === 'number' && Number.isFinite(entry.timestampGMT) ? entry.timestampGMT : 0;
1004
+ entries.push({ date: day, kg, ts });
1005
+ };
1006
+ for (const summary of objectArray(payload.dailyWeightSummaries)) {
1007
+ const day = datePart(firstString(summary, ['summaryDate', 'calendarDate', 'date'])) ?? fallbackDay;
1008
+ for (const metric of objectArray(summary.allWeightMetrics))
1009
+ pushEntry(metric, day);
1010
+ }
1011
+ for (const key of ['dateWeightList', 'allWeightMetrics']) {
1012
+ for (const entry of objectArray(payload[key]))
1013
+ pushEntry(entry, fallbackDay);
1014
+ }
1015
+ if (firstNumber(payload, ['weight', 'weightValue', 'weightInKg']) !== null)
1016
+ pushEntry(payload, fallbackDay);
1017
+ return entries;
1018
+ }
1019
+ async function importGarminWeight(database, date, payload, rawHash) {
1020
+ const fallbackDay = datePart(date);
1021
+ const latestByDay = new Map();
1022
+ for (const entry of collectGarminWeightEntries(payload, fallbackDay)) {
1023
+ const current = latestByDay.get(entry.date);
1024
+ if (!current || entry.ts >= current.ts)
1025
+ latestByDay.set(entry.date, { kg: entry.kg, ts: entry.ts });
1026
+ }
1027
+ for (const [metricDate, { kg }] of latestByDay) {
1028
+ await upsertDailyMetric(database, 'garmin', metricDate, 'weight_kg', kg, null, 'kg', rawHash);
1029
+ }
1030
+ }
971
1031
  async function importNutrition(database, provider, date, remoteId, payload, rawHash) {
972
1032
  const nutritionDate = datePart(date) ?? datePart(firstString(payload, ['id', 'date', 'calendarDate'])) ?? null;
973
1033
  if (!nutritionDate)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catence",
3
- "version": "0.2.1-beta.4",
3
+ "version": "0.2.1-beta.5",
4
4
  "description": "Local MCP access to normalized Garmin, Intervals.icu, and targeted Strava fitness data.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {