@einlogic/mcp-fitbit 1.0.1 → 2.1.0
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/README.md +185 -209
- package/build/account.js +32 -0
- package/build/activities.js +12 -14
- package/build/activity-timeseries.js +14 -20
- package/build/auth-tools.js +31 -0
- package/build/auth.js +197 -105
- package/build/azm-timeseries.js +4 -8
- package/build/compat/api.js +136 -0
- package/build/compat/convert.js +434 -0
- package/build/compat/tools.js +432 -0
- package/build/config.js +102 -32
- package/build/daily-activity.js +35 -7
- package/build/data-points.js +44 -0
- package/build/heart-rate.js +28 -98
- package/build/hrv.js +15 -15
- package/build/index.js +36 -20
- package/build/nutrition.js +41 -152
- package/build/profile.js +6 -9
- package/build/sleep.js +12 -16
- package/build/utils.js +193 -50
- package/build/weight.js +12 -13
- package/package.json +74 -74
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure conversion helpers for the Fitbit-compatible output mode.
|
|
3
|
+
*
|
|
4
|
+
* These map Google Health API response shapes and units onto the shapes and
|
|
5
|
+
* units the legacy Fitbit Web API returned, so existing consumers (e.g. the
|
|
6
|
+
* "Heather's Coach" project) keep working. No network access happens here.
|
|
7
|
+
*/
|
|
8
|
+
// === Small numeric / formatting helpers ===
|
|
9
|
+
/** int64 values arrive as strings; missing values become 0. */
|
|
10
|
+
export function num(value) {
|
|
11
|
+
if (value === undefined || value === null || value === '')
|
|
12
|
+
return 0;
|
|
13
|
+
const parsed = Number(value);
|
|
14
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
15
|
+
}
|
|
16
|
+
export function round(value, decimals) {
|
|
17
|
+
const factor = 10 ** decimals;
|
|
18
|
+
return Math.round(value * factor) / factor;
|
|
19
|
+
}
|
|
20
|
+
/** "1800s" or "1800.5s" -> seconds. */
|
|
21
|
+
export function durationSeconds(value) {
|
|
22
|
+
if (!value)
|
|
23
|
+
return 0;
|
|
24
|
+
return num(value.replace(/s$/, ''));
|
|
25
|
+
}
|
|
26
|
+
const pad = (n, width = 2) => String(n).padStart(width, '0');
|
|
27
|
+
export function civilDate(c) {
|
|
28
|
+
const date = c && 'date' in c ? c.date : c;
|
|
29
|
+
if (!date?.year || !date.month || !date.day)
|
|
30
|
+
return undefined;
|
|
31
|
+
return `${pad(date.year, 4)}-${pad(date.month)}-${pad(date.day)}`;
|
|
32
|
+
}
|
|
33
|
+
function civilTimeOfDay(c) {
|
|
34
|
+
return `${pad(c?.time?.hours ?? 0)}:${pad(c?.time?.minutes ?? 0)}:${pad(c?.time?.seconds ?? 0)}`;
|
|
35
|
+
}
|
|
36
|
+
/** Local datetime as Fitbit formats it: 2026-11-03T22:15:30.000 */
|
|
37
|
+
export function civilLocalIso(c) {
|
|
38
|
+
const date = civilDate(c);
|
|
39
|
+
return date ? `${date}T${civilTimeOfDay(c)}.000` : undefined;
|
|
40
|
+
}
|
|
41
|
+
/** Applies a Google UTC offset ("-25200s") to a UTC instant and formats it as local time. */
|
|
42
|
+
export function utcToLocalIso(utcIso, offset) {
|
|
43
|
+
const ms = Date.parse(utcIso) + durationSeconds(offset) * 1000;
|
|
44
|
+
return new Date(ms).toISOString().slice(0, 23);
|
|
45
|
+
}
|
|
46
|
+
// === Date arithmetic (civil dates, YYYY-MM-DD) ===
|
|
47
|
+
export function addDays(date, days) {
|
|
48
|
+
const d = new Date(`${date}T00:00:00Z`);
|
|
49
|
+
d.setUTCDate(d.getUTCDate() + days);
|
|
50
|
+
return d.toISOString().slice(0, 10);
|
|
51
|
+
}
|
|
52
|
+
function addMonths(date, months) {
|
|
53
|
+
const d = new Date(`${date}T00:00:00Z`);
|
|
54
|
+
d.setUTCMonth(d.getUTCMonth() + months);
|
|
55
|
+
return d.toISOString().slice(0, 10);
|
|
56
|
+
}
|
|
57
|
+
/** Inclusive number of days between two civil dates. */
|
|
58
|
+
export function inclusiveDays(start, end) {
|
|
59
|
+
const ms = Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`);
|
|
60
|
+
return Math.round(ms / 86_400_000) + 1;
|
|
61
|
+
}
|
|
62
|
+
/** All civil dates from start to end, inclusive. */
|
|
63
|
+
export function eachDate(start, end) {
|
|
64
|
+
const out = [];
|
|
65
|
+
for (let d = start; d <= end; d = addDays(d, 1))
|
|
66
|
+
out.push(d);
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* First date of a Fitbit-style period ending on endDate (inclusive).
|
|
71
|
+
* '1d' is just endDate, '7d'/'1w' is the 7 days ending endDate, '1m' is one calendar month, etc.
|
|
72
|
+
*/
|
|
73
|
+
export function periodStartDate(endDate, period) {
|
|
74
|
+
const match = /^(\d+)([dwmy])$/.exec(period);
|
|
75
|
+
if (!match)
|
|
76
|
+
throw new Error(`Unsupported period '${period}'`);
|
|
77
|
+
const n = Number(match[1]);
|
|
78
|
+
switch (match[2]) {
|
|
79
|
+
case 'd':
|
|
80
|
+
return addDays(endDate, -(n - 1));
|
|
81
|
+
case 'w':
|
|
82
|
+
return addDays(endDate, -(7 * n - 1));
|
|
83
|
+
case 'm':
|
|
84
|
+
return addDays(addMonths(endDate, -n), 1);
|
|
85
|
+
default:
|
|
86
|
+
return addDays(addMonths(endDate, -12 * n), 1);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// === Time zones ===
|
|
90
|
+
function zoneOffsetMs(timeZone, utcMs) {
|
|
91
|
+
const whole = Math.floor(utcMs / 1000) * 1000;
|
|
92
|
+
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', {
|
|
93
|
+
timeZone,
|
|
94
|
+
hourCycle: 'h23',
|
|
95
|
+
year: 'numeric',
|
|
96
|
+
month: '2-digit',
|
|
97
|
+
day: '2-digit',
|
|
98
|
+
hour: '2-digit',
|
|
99
|
+
minute: '2-digit',
|
|
100
|
+
second: '2-digit',
|
|
101
|
+
})
|
|
102
|
+
.formatToParts(new Date(whole))
|
|
103
|
+
.map((p) => [p.type, p.value]));
|
|
104
|
+
const asUtc = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour), Number(parts.minute), Number(parts.second));
|
|
105
|
+
return asUtc - whole;
|
|
106
|
+
}
|
|
107
|
+
/** The UTC instant (ms) at which a civil date starts in the given IANA time zone. */
|
|
108
|
+
export function zonedMidnightMs(date, timeZone) {
|
|
109
|
+
const [y, m, d] = date.split('-').map(Number);
|
|
110
|
+
const guess = Date.UTC(y, m - 1, d);
|
|
111
|
+
const first = guess - zoneOffsetMs(timeZone, guess);
|
|
112
|
+
return guess - zoneOffsetMs(timeZone, first);
|
|
113
|
+
}
|
|
114
|
+
/** RFC 3339 timestamp at which a civil date starts in the given time zone. */
|
|
115
|
+
export function zonedMidnightIso(date, timeZone) {
|
|
116
|
+
return new Date(zonedMidnightMs(date, timeZone)).toISOString();
|
|
117
|
+
}
|
|
118
|
+
/** The current civil date in the given time zone. */
|
|
119
|
+
export function todayInZone(timeZone, now = new Date()) {
|
|
120
|
+
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-CA', {
|
|
121
|
+
timeZone,
|
|
122
|
+
year: 'numeric',
|
|
123
|
+
month: '2-digit',
|
|
124
|
+
day: '2-digit',
|
|
125
|
+
})
|
|
126
|
+
.formatToParts(now)
|
|
127
|
+
.map((p) => [p.type, p.value]));
|
|
128
|
+
return `${parts.year}-${parts.month}-${parts.day}`;
|
|
129
|
+
}
|
|
130
|
+
// === Units ===
|
|
131
|
+
/** Weight in the user's configured unit (Fitbit followed the account unit; default pounds). */
|
|
132
|
+
export function convertWeight(grams, weightUnit) {
|
|
133
|
+
const unit = weightUnit ?? '';
|
|
134
|
+
if (unit.includes('KILOGRAM'))
|
|
135
|
+
return round(grams / 1000, 1);
|
|
136
|
+
if (unit.includes('STONE'))
|
|
137
|
+
return round(grams / 6350.29318, 2);
|
|
138
|
+
return round(grams / 453.59237, 1);
|
|
139
|
+
}
|
|
140
|
+
/** Distance in the user's configured unit (Fitbit followed the account unit; default miles). */
|
|
141
|
+
export function convertDistance(mm, distanceUnit) {
|
|
142
|
+
if ((distanceUnit ?? '').includes('KILOMETER'))
|
|
143
|
+
return round(mm / 1_000_000, 2);
|
|
144
|
+
return round(mm / 1_609_344, 2);
|
|
145
|
+
}
|
|
146
|
+
/** Water volume in the user's configured unit (Fitbit default fl oz; ml when metric). */
|
|
147
|
+
export function convertWater(ml, waterUnit) {
|
|
148
|
+
const unit = waterUnit ?? '';
|
|
149
|
+
if (unit.includes('FL_OZ'))
|
|
150
|
+
return round(ml / 29.5735, 1);
|
|
151
|
+
if (unit.includes('CUP'))
|
|
152
|
+
return round(ml / 236.588, 2);
|
|
153
|
+
return Math.round(ml);
|
|
154
|
+
}
|
|
155
|
+
/** Fitbit log ids are plain numbers; Google ids can exceed 2^53, so those stay strings. */
|
|
156
|
+
function toLogId(name) {
|
|
157
|
+
const tail = name?.split('/').pop() ?? '';
|
|
158
|
+
return /^\d+$/.test(tail) && Number.isSafeInteger(Number(tail))
|
|
159
|
+
? Number(tail)
|
|
160
|
+
: tail;
|
|
161
|
+
}
|
|
162
|
+
/** Lower is better: Fitbit-originated entries win over Health Connect copies. */
|
|
163
|
+
function sourcePriority(platform) {
|
|
164
|
+
if (platform?.includes('FITBIT'))
|
|
165
|
+
return 0;
|
|
166
|
+
if (platform === 'GOOGLE_WEB_API')
|
|
167
|
+
return 1;
|
|
168
|
+
if (platform === 'HEALTH_CONNECT')
|
|
169
|
+
return 3;
|
|
170
|
+
return 2;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* One weigh-in is often stored several times (once per API client, plus Health Connect
|
|
174
|
+
* copies that carry a UTC "civil" time). Entries with the same instant are collapsed into one,
|
|
175
|
+
* preferring Fitbit-originated sources. Local date/time come from the instant and its UTC
|
|
176
|
+
* offset because the civil time on copies can be wrong.
|
|
177
|
+
*/
|
|
178
|
+
export function mapWeight(points, weightUnit) {
|
|
179
|
+
const best = new Map();
|
|
180
|
+
for (const p of points) {
|
|
181
|
+
const sample = p.weight?.sampleTime;
|
|
182
|
+
if (p.weight?.weightGrams === undefined || !sample)
|
|
183
|
+
continue;
|
|
184
|
+
const key = sample.physicalTime ?? civilLocalIso(sample.civilTime) ?? '';
|
|
185
|
+
if (!key)
|
|
186
|
+
continue;
|
|
187
|
+
const current = best.get(key);
|
|
188
|
+
if (!current ||
|
|
189
|
+
sourcePriority(p.dataSource?.platform) <
|
|
190
|
+
sourcePriority(current.dataSource?.platform)) {
|
|
191
|
+
best.set(key, p);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const logs = [];
|
|
195
|
+
for (const p of best.values()) {
|
|
196
|
+
const sample = p.weight.sampleTime;
|
|
197
|
+
const local = sample.physicalTime && sample.utcOffset
|
|
198
|
+
? utcToLocalIso(sample.physicalTime, sample.utcOffset)
|
|
199
|
+
: (civilLocalIso(sample.civilTime) ??
|
|
200
|
+
(sample.physicalTime
|
|
201
|
+
? utcToLocalIso(sample.physicalTime)
|
|
202
|
+
: undefined));
|
|
203
|
+
if (!local)
|
|
204
|
+
continue;
|
|
205
|
+
logs.push({
|
|
206
|
+
date: local.slice(0, 10),
|
|
207
|
+
logId: toLogId(p.name),
|
|
208
|
+
source: 'API',
|
|
209
|
+
time: local.slice(11, 19),
|
|
210
|
+
weight: convertWeight(p.weight.weightGrams, weightUnit),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
logs.sort((a, b) => `${a.date}T${a.time}`.localeCompare(`${b.date}T${b.time}`));
|
|
214
|
+
return { weight: logs };
|
|
215
|
+
}
|
|
216
|
+
// === Sleep ===
|
|
217
|
+
const STAGES_LEVELS = {
|
|
218
|
+
AWAKE: 'wake',
|
|
219
|
+
LIGHT: 'light',
|
|
220
|
+
DEEP: 'deep',
|
|
221
|
+
REM: 'rem',
|
|
222
|
+
};
|
|
223
|
+
const CLASSIC_LEVELS = {
|
|
224
|
+
AWAKE: 'awake',
|
|
225
|
+
ASLEEP: 'asleep',
|
|
226
|
+
RESTLESS: 'restless',
|
|
227
|
+
};
|
|
228
|
+
export function mapSleepPoint(point) {
|
|
229
|
+
const sleep = point.sleep;
|
|
230
|
+
const interval = sleep?.interval;
|
|
231
|
+
// civil times are not always populated; derive the wake date from the end instant + offset
|
|
232
|
+
const dateOfSleep = civilDate(interval?.civilEndTime) ??
|
|
233
|
+
(interval?.endTime
|
|
234
|
+
? utcToLocalIso(interval.endTime, interval.endUtcOffset).slice(0, 10)
|
|
235
|
+
: undefined);
|
|
236
|
+
if (!sleep || !dateOfSleep)
|
|
237
|
+
return null;
|
|
238
|
+
const stageTypes = (sleep.summary?.stagesSummary ?? []).map((s) => s.type);
|
|
239
|
+
const isStages = sleep.type === 'STAGES' ||
|
|
240
|
+
(sleep.type !== 'CLASSIC' &&
|
|
241
|
+
stageTypes.some((t) => t === 'DEEP' || t === 'LIGHT' || t === 'REM'));
|
|
242
|
+
const levelNames = isStages ? STAGES_LEVELS : CLASSIC_LEVELS;
|
|
243
|
+
const summary = {};
|
|
244
|
+
for (const name of Object.values(levelNames)) {
|
|
245
|
+
summary[name] = { count: 0, minutes: 0 };
|
|
246
|
+
}
|
|
247
|
+
for (const s of sleep.summary?.stagesSummary ?? []) {
|
|
248
|
+
const name = levelNames[s.type ?? ''];
|
|
249
|
+
if (name)
|
|
250
|
+
summary[name] = { count: num(s.count), minutes: num(s.minutes) };
|
|
251
|
+
}
|
|
252
|
+
const data = (sleep.stages ?? [])
|
|
253
|
+
.filter((s) => s.startTime && s.endTime && levelNames[s.type ?? ''])
|
|
254
|
+
.map((s) => ({
|
|
255
|
+
dateTime: utcToLocalIso(s.startTime, s.startUtcOffset),
|
|
256
|
+
level: levelNames[s.type],
|
|
257
|
+
seconds: Math.round((Date.parse(s.endTime) - Date.parse(s.startTime)) / 1000),
|
|
258
|
+
}));
|
|
259
|
+
const timeInBed = num(sleep.summary?.minutesInSleepPeriod) ||
|
|
260
|
+
(interval?.startTime && interval.endTime
|
|
261
|
+
? Math.round((Date.parse(interval.endTime) - Date.parse(interval.startTime)) / 60_000)
|
|
262
|
+
: 0);
|
|
263
|
+
return {
|
|
264
|
+
logId: toLogId(point.name),
|
|
265
|
+
dateOfSleep,
|
|
266
|
+
startTime: civilLocalIso(interval?.civilStartTime) ??
|
|
267
|
+
(interval?.startTime ? utcToLocalIso(interval.startTime, interval.startUtcOffset) : undefined),
|
|
268
|
+
endTime: civilLocalIso(interval?.civilEndTime) ??
|
|
269
|
+
(interval?.endTime ? utcToLocalIso(interval.endTime, interval.endUtcOffset) : undefined),
|
|
270
|
+
duration: timeInBed * 60_000,
|
|
271
|
+
minutesAsleep: num(sleep.summary?.minutesAsleep),
|
|
272
|
+
minutesAwake: num(sleep.summary?.minutesAwake),
|
|
273
|
+
minutesToFallAsleep: num(sleep.summary?.minutesToFallAsleep),
|
|
274
|
+
minutesAfterWakeUp: num(sleep.summary?.minutesAfterWakeUp),
|
|
275
|
+
timeInBed,
|
|
276
|
+
isMainSleep: sleep.metadata?.mainSleep ?? !sleep.metadata?.nap,
|
|
277
|
+
type: isStages ? 'stages' : 'classic',
|
|
278
|
+
levels: { summary, data },
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
export function mapSleep(points) {
|
|
282
|
+
const logs = points
|
|
283
|
+
.map(mapSleepPoint)
|
|
284
|
+
.filter((l) => l !== null);
|
|
285
|
+
logs.sort((a, b) => (a.startTime ?? '').localeCompare(b.startTime ?? ''));
|
|
286
|
+
return { sleep: logs };
|
|
287
|
+
}
|
|
288
|
+
// === HRV and resting heart rate ===
|
|
289
|
+
export function mapHrv(points) {
|
|
290
|
+
const rows = points
|
|
291
|
+
.map((p) => {
|
|
292
|
+
const d = p.dailyHeartRateVariability;
|
|
293
|
+
const dateTime = civilDate(d?.date);
|
|
294
|
+
if (!d || !dateTime)
|
|
295
|
+
return null;
|
|
296
|
+
const value = {};
|
|
297
|
+
if (d.averageHeartRateVariabilityMilliseconds !== undefined) {
|
|
298
|
+
value.dailyRmssd = round(d.averageHeartRateVariabilityMilliseconds, 3);
|
|
299
|
+
}
|
|
300
|
+
if (d.deepSleepRootMeanSquareOfSuccessiveDifferencesMilliseconds !== undefined) {
|
|
301
|
+
value.deepRmssd = round(d.deepSleepRootMeanSquareOfSuccessiveDifferencesMilliseconds, 3);
|
|
302
|
+
}
|
|
303
|
+
return { value, dateTime };
|
|
304
|
+
})
|
|
305
|
+
.filter((r) => r !== null);
|
|
306
|
+
rows.sort((a, b) => a.dateTime.localeCompare(b.dateTime));
|
|
307
|
+
return { hrv: rows };
|
|
308
|
+
}
|
|
309
|
+
/** date -> resting heart rate (bpm) */
|
|
310
|
+
export function restingHeartRateByDate(points) {
|
|
311
|
+
const out = new Map();
|
|
312
|
+
for (const p of points) {
|
|
313
|
+
const d = p.dailyRestingHeartRate;
|
|
314
|
+
const date = civilDate(d?.date);
|
|
315
|
+
const bpm = num(d?.beatsPerMinute);
|
|
316
|
+
if (date && bpm > 0)
|
|
317
|
+
out.set(date, bpm);
|
|
318
|
+
}
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
const ZONE_MAP = [
|
|
322
|
+
{ name: 'Out of Range', google: 'LIGHT' },
|
|
323
|
+
{ name: 'Fat Burn', google: 'MODERATE' },
|
|
324
|
+
{ name: 'Cardio', google: 'VIGOROUS' },
|
|
325
|
+
{ name: 'Peak', google: 'PEAK' },
|
|
326
|
+
];
|
|
327
|
+
/** date -> zone type -> bpm bounds, from the account's daily-heart-rate-zones data. */
|
|
328
|
+
export function zoneBoundsByDate(points) {
|
|
329
|
+
const out = new Map();
|
|
330
|
+
for (const p of points) {
|
|
331
|
+
const d = p.dailyHeartRateZones;
|
|
332
|
+
const date = civilDate(d?.date);
|
|
333
|
+
if (!date)
|
|
334
|
+
continue;
|
|
335
|
+
const bounds = {};
|
|
336
|
+
for (const z of d?.heartRateZones ?? []) {
|
|
337
|
+
if (z.heartRateZoneType) {
|
|
338
|
+
bounds[z.heartRateZoneType] = {
|
|
339
|
+
min: num(z.minBeatsPerMinute),
|
|
340
|
+
max: num(z.maxBeatsPerMinute),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
out.set(date, bounds);
|
|
345
|
+
}
|
|
346
|
+
return out;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Builds Fitbit-style heartRateZones for one day.
|
|
350
|
+
* Minutes prefer Active Zone Minutes (minute-granular, matches Fitbit's old counts) and fall
|
|
351
|
+
* back to time-in-zone; Out of Range always comes from time-in-zone. Returns null when the
|
|
352
|
+
* day has neither source.
|
|
353
|
+
*/
|
|
354
|
+
export function buildFitbitZones(day) {
|
|
355
|
+
const tizRows = day.timeInZone?.timeInHeartRateZone?.timeInHeartRateZones;
|
|
356
|
+
const azm = day.azm?.activeZoneMinutes;
|
|
357
|
+
if (!tizRows && !azm)
|
|
358
|
+
return null;
|
|
359
|
+
const seconds = (zone) => (tizRows ?? [])
|
|
360
|
+
.filter((r) => r.heartRateZone === zone)
|
|
361
|
+
.reduce((sum, r) => sum + durationSeconds(r.duration), 0);
|
|
362
|
+
const fromTime = (zone) => Math.round(seconds(zone) / 60);
|
|
363
|
+
const minutes = {
|
|
364
|
+
LIGHT: fromTime('LIGHT'),
|
|
365
|
+
MODERATE: azm ? num(azm.sumInFatBurnHeartZone) : fromTime('MODERATE'),
|
|
366
|
+
VIGOROUS: azm
|
|
367
|
+
? Math.round(num(azm.sumInCardioHeartZone) / 2)
|
|
368
|
+
: fromTime('VIGOROUS'),
|
|
369
|
+
PEAK: azm ? Math.round(num(azm.sumInPeakHeartZone) / 2) : fromTime('PEAK'),
|
|
370
|
+
};
|
|
371
|
+
const kcal = day.calories?.caloriesInHeartRateZone?.caloriesInHeartRateZones;
|
|
372
|
+
return ZONE_MAP.map(({ name, google }) => {
|
|
373
|
+
const bounds = day.bounds?.[google];
|
|
374
|
+
const burned = kcal?.find((k) => k.heartRateZone === google)?.kcal;
|
|
375
|
+
return {
|
|
376
|
+
name,
|
|
377
|
+
...(bounds ? { min: bounds.min, max: bounds.max } : {}),
|
|
378
|
+
minutes: minutes[google],
|
|
379
|
+
...(burned !== undefined ? { caloriesOut: Math.round(burned) } : {}),
|
|
380
|
+
};
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
// === Daily roll-ups ===
|
|
384
|
+
export function rollupDate(point) {
|
|
385
|
+
return civilDate(point.civilStartTime);
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Fitbit activity-level minutes from raw `activity-level` data points.
|
|
389
|
+
*
|
|
390
|
+
* Google does not support dailyRollUp for this data type (only list/reconcile), so the
|
|
391
|
+
* per-minute points of one day are summed here. Google leaves sleep and off-wrist time
|
|
392
|
+
* untyped; Fitbit counts that as sedentary, so sedentary is the rest of the day.
|
|
393
|
+
*/
|
|
394
|
+
export function activityLevelMinutes(points, minutesInDay = 1440) {
|
|
395
|
+
const totals = {};
|
|
396
|
+
let seen = 0;
|
|
397
|
+
for (const point of points) {
|
|
398
|
+
const level = point.activityLevel;
|
|
399
|
+
const start = Date.parse(level?.interval?.startTime ?? '');
|
|
400
|
+
const end = Date.parse(level?.interval?.endTime ?? '');
|
|
401
|
+
if (!level?.activityLevelType || Number.isNaN(start) || Number.isNaN(end))
|
|
402
|
+
continue;
|
|
403
|
+
totals[level.activityLevelType] =
|
|
404
|
+
(totals[level.activityLevelType] ?? 0) + (end - start) / 60000;
|
|
405
|
+
seen++;
|
|
406
|
+
}
|
|
407
|
+
if (seen === 0)
|
|
408
|
+
return null;
|
|
409
|
+
const minutes = (type) => Math.round(totals[type] ?? 0);
|
|
410
|
+
const lightly = minutes('LIGHTLY_ACTIVE');
|
|
411
|
+
const fairly = minutes('MODERATELY_ACTIVE');
|
|
412
|
+
const very = minutes('VERY_ACTIVE');
|
|
413
|
+
return {
|
|
414
|
+
sedentaryMinutes: Math.max(0, minutesInDay - lightly - fairly - very),
|
|
415
|
+
lightlyActiveMinutes: lightly,
|
|
416
|
+
fairlyActiveMinutes: fairly,
|
|
417
|
+
veryActiveMinutes: very,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
/** Fitbit's food-log summary (calories kcal; carbs/fat/fiber/protein g; sodium mg). */
|
|
421
|
+
export function nutritionSummary(point) {
|
|
422
|
+
const log = point?.nutritionLog;
|
|
423
|
+
if (!log)
|
|
424
|
+
return null;
|
|
425
|
+
const grams = (nutrient) => log.nutrients?.find((n) => n.nutrient === nutrient)?.quantity?.gramsSum ?? 0;
|
|
426
|
+
return {
|
|
427
|
+
calories: Math.round(log.energy?.kcalSum ?? 0),
|
|
428
|
+
carbs: round(log.totalCarbohydrate?.gramsSum ?? grams('CARBOHYDRATES'), 1),
|
|
429
|
+
fat: round(log.totalFat?.gramsSum ?? 0, 1),
|
|
430
|
+
fiber: round(grams('DIETARY_FIBER'), 1),
|
|
431
|
+
protein: round(grams('PROTEIN'), 1),
|
|
432
|
+
sodium: Math.round(grams('SODIUM') * 1000),
|
|
433
|
+
};
|
|
434
|
+
}
|