@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/build/auth.js CHANGED
@@ -1,36 +1,40 @@
1
1
  import dotenv from 'dotenv';
2
2
  import express from 'express';
3
+ import crypto from 'crypto';
3
4
  import open from 'open';
4
5
  import path from 'path';
5
6
  import { AuthorizationCode } from 'simple-oauth2';
6
7
  import { fileURLToPath } from 'url';
7
8
  import fs from 'fs/promises';
8
9
  import { existsSync } from 'fs';
9
- import { FITBIT_OAUTH_CONFIG } from './config.js';
10
+ import { GOOGLE_OAUTH_CONFIG, GOOGLE_HEALTH_API_BASE, FILE_PATHS, ERROR_MESSAGES, HTTP_CONFIG, resolveClientCredentials, } from './config.js';
10
11
  // Determine the directory of the current module (build/auth.js)
11
12
  const currentFilename = fileURLToPath(import.meta.url);
12
13
  const currentDirname = path.dirname(currentFilename);
13
14
  // Load environment variables from .env file located in the project root
14
15
  const envPath = path.resolve(currentDirname, '..', '.env');
15
16
  dotenv.config({ path: envPath });
16
- // Fitbit OAuth2 Configuration
17
- const fitbitConfig = {
17
+ // Google OAuth2 Configuration
18
+ const clientCredentials = resolveClientCredentials();
19
+ const googleConfig = {
18
20
  client: {
19
- id: process.env.FITBIT_CLIENT_ID || '',
20
- secret: process.env.FITBIT_CLIENT_SECRET || '',
21
+ id: clientCredentials.id,
22
+ secret: clientCredentials.secret,
21
23
  },
22
24
  auth: {
23
- tokenHost: 'https://api.fitbit.com',
24
- authorizePath: 'https://www.fitbit.com/oauth2/authorize',
25
- tokenPath: 'https://api.fitbit.com/oauth2/token',
25
+ tokenHost: GOOGLE_OAUTH_CONFIG.TOKEN_HOST,
26
+ tokenPath: GOOGLE_OAUTH_CONFIG.TOKEN_PATH,
27
+ authorizeHost: GOOGLE_OAUTH_CONFIG.AUTHORIZE_HOST,
28
+ authorizePath: GOOGLE_OAUTH_CONFIG.AUTHORIZE_PATH,
26
29
  },
27
30
  options: {
28
- authorizationMethod: 'header',
31
+ // Google expects client_id/client_secret in the form body of token requests
32
+ authorizationMethod: 'body',
29
33
  },
30
34
  };
31
35
  // OAuth2 Redirect URI and local server port
32
- const REDIRECT_URI = 'http://localhost:3000/callback';
33
- const PORT = 3000;
36
+ const REDIRECT_URI = GOOGLE_OAUTH_CONFIG.CALLBACK_URI;
37
+ const PORT = Number(new URL(REDIRECT_URI).port) || 3000;
34
38
  // --- State Management ---
35
39
  // Storage for the access token and token data
36
40
  let accessToken = null;
@@ -38,16 +42,15 @@ let tokenData = null;
38
42
  // Holds the temporary HTTP server instance used for the OAuth callback
39
43
  let oauthServer = null;
40
44
  // --- File paths for token persistence ---
41
- const TOKEN_FILE_PATH = path.resolve(currentDirname, '..', '.fitbit-token.json');
45
+ const TOKEN_FILE_PATH = path.resolve(currentDirname, '..', FILE_PATHS.TOKEN_STORAGE);
42
46
  // --- OAuth Client Initialization ---
43
- const oauthClient = new AuthorizationCode(fitbitConfig);
47
+ const oauthClient = new AuthorizationCode(googleConfig);
44
48
  /**
45
49
  * Saves the token data to a file for persistence
46
- * @param tokenData The token data to save
47
50
  */
48
- async function saveTokenToFile(tokenData) {
51
+ async function saveTokenToFile(data) {
49
52
  try {
50
- await fs.writeFile(TOKEN_FILE_PATH, JSON.stringify(tokenData, null, 2), 'utf8');
53
+ await fs.writeFile(TOKEN_FILE_PATH, JSON.stringify(data, null, 2), 'utf8');
51
54
  console.error(`Token saved to ${TOKEN_FILE_PATH}`);
52
55
  }
53
56
  catch (error) {
@@ -74,11 +77,82 @@ async function loadTokenFromFile() {
74
77
  return null;
75
78
  }
76
79
  }
77
- // --- Fitbit Authorization Flow ---
78
80
  /**
79
- * Initiates the Fitbit OAuth2 authorization code flow.
81
+ * Checks that the authorized Google Account is linked to Google Health.
82
+ * @returns true when linked (or when the check could not decide), false when
83
+ * the API reports ACCOUNT_NOT_LINKED.
84
+ */
85
+ async function verifyAccountLinked(token) {
86
+ try {
87
+ const response = await fetch(`${GOOGLE_HEALTH_API_BASE}/users/me/identity`, {
88
+ headers: {
89
+ 'User-Agent': HTTP_CONFIG.USER_AGENT,
90
+ Authorization: `Bearer ${token}`,
91
+ Accept: 'application/json',
92
+ },
93
+ });
94
+ if (response.ok) {
95
+ console.error('Google Health identity verified.');
96
+ return true;
97
+ }
98
+ const body = await response.text();
99
+ if (response.status === 400 && body.includes('ACCOUNT_NOT_LINKED')) {
100
+ console.error(ERROR_MESSAGES.ACCOUNT_NOT_LINKED);
101
+ return false;
102
+ }
103
+ console.error(`Identity check returned HTTP ${response.status}: ${body}. Continuing.`);
104
+ return true;
105
+ }
106
+ catch (error) {
107
+ console.error('Identity check failed, continuing:', error);
108
+ return true;
109
+ }
110
+ }
111
+ function isExpired(data) {
112
+ if (!data.expires_at)
113
+ return false;
114
+ // Refresh a minute early to avoid racing the expiry
115
+ return new Date(data.expires_at).getTime() - 60_000 < Date.now();
116
+ }
117
+ /**
118
+ * Refreshes the access token if it has expired. Google does not return a new
119
+ * refresh token on refresh, so the existing one is carried over.
120
+ */
121
+ async function refreshTokenIfNeeded() {
122
+ if (!tokenData || !isExpired(tokenData)) {
123
+ return;
124
+ }
125
+ const refreshToken = tokenData.refresh_token;
126
+ if (!refreshToken) {
127
+ console.error('Token is expired and no refresh token is available.');
128
+ accessToken = null;
129
+ tokenData = null;
130
+ return;
131
+ }
132
+ console.error('Token is expired. Attempting to refresh...');
133
+ try {
134
+ const accessTokenObj = oauthClient.createToken(tokenData);
135
+ const refreshed = await accessTokenObj.refresh();
136
+ const refreshedData = refreshed.token;
137
+ if (!refreshedData.refresh_token) {
138
+ refreshedData.refresh_token = refreshToken;
139
+ }
140
+ tokenData = refreshedData;
141
+ accessToken = refreshedData.access_token;
142
+ await saveTokenToFile(refreshedData);
143
+ console.error('Token refreshed and saved successfully.');
144
+ }
145
+ catch (refreshError) {
146
+ console.error('Failed to refresh token:', refreshError);
147
+ accessToken = null;
148
+ tokenData = null;
149
+ }
150
+ }
151
+ // --- Google Authorization Flow ---
152
+ /**
153
+ * Initiates the Google OAuth2 authorization code flow.
80
154
  * Starts a temporary local web server to handle the redirect callback.
81
- * Opens the user's browser to the Fitbit authorization page.
155
+ * Opens the user's browser to the Google consent page.
82
156
  */
83
157
  export function startAuthorizationFlow() {
84
158
  // Prevent multiple authorization flows from running simultaneously
@@ -87,36 +161,57 @@ export function startAuthorizationFlow() {
87
161
  return;
88
162
  }
89
163
  // Ensure Client ID and Secret are loaded before starting
90
- if (!fitbitConfig.client.id || !fitbitConfig.client.secret) {
91
- console.error('Error: Fitbit Client ID or Secret not found. Check environment variables.');
164
+ if (!googleConfig.client.id || !googleConfig.client.secret) {
165
+ console.error('Error: Google Health Client ID or Secret not found. Check environment variables.');
92
166
  return;
93
167
  }
94
168
  const app = express();
95
- // Generate the Fitbit authorization URL
96
- const authorizationUri = oauthClient.authorizeURL({
169
+ // CSRF protection: the callback must echo this value
170
+ const expectedState = crypto.randomBytes(16).toString('hex');
171
+ // Generate the Google authorization URL
172
+ const baseAuthorizationUri = new URL(oauthClient.authorizeURL({
97
173
  redirect_uri: REDIRECT_URI,
98
- // Define necessary scopes required by the application
99
- scope: FITBIT_OAUTH_CONFIG.SCOPES,
100
- });
101
- // Route to initiate the authorization flow by redirecting the user to Fitbit
174
+ scope: GOOGLE_OAUTH_CONFIG.SCOPES,
175
+ state: expectedState,
176
+ }));
177
+ // Request a refresh token, and force the consent screen so one is issued
178
+ baseAuthorizationUri.searchParams.set('access_type', 'offline');
179
+ baseAuthorizationUri.searchParams.set('prompt', 'consent');
180
+ const authorizationUri = baseAuthorizationUri.toString();
181
+ const shutdownServer = (reason) => {
182
+ if (oauthServer) {
183
+ oauthServer.close(() => {
184
+ console.error(reason);
185
+ });
186
+ oauthServer = null;
187
+ }
188
+ };
189
+ // Route to initiate the authorization flow by redirecting the user to Google
102
190
  app.get('/auth', (req, res) => {
103
- console.error('Redirecting to Fitbit for authorization...');
191
+ console.error('Redirecting to Google for authorization...');
104
192
  res.redirect(authorizationUri);
105
193
  });
106
- // Callback route that Fitbit redirects to after user authorization
194
+ // Callback route that Google redirects to after user authorization
107
195
  app.get('/callback', async (req, res) => {
108
196
  const code = req.query.code;
197
+ const state = req.query.state;
198
+ if (req.query.error) {
199
+ console.error(`Authorization denied or failed: ${req.query.error}`);
200
+ res.status(400).send(`Authorization failed: ${req.query.error}`);
201
+ shutdownServer('OAuth server closed after authorization error.');
202
+ return;
203
+ }
204
+ if (state !== expectedState) {
205
+ console.error('OAuth state mismatch in callback.');
206
+ res.status(400).send('Error: Invalid state parameter.');
207
+ shutdownServer('OAuth server closed due to state mismatch.');
208
+ return;
209
+ }
109
210
  // Handle cases where the authorization code is missing
110
211
  if (!code) {
111
212
  console.error('Authorization code missing in callback.');
112
213
  res.status(400).send('Error: Authorization code missing.');
113
- // Attempt to close the server if it exists
114
- if (oauthServer) {
115
- oauthServer.close(() => {
116
- console.error('OAuth server closed due to missing code.');
117
- });
118
- oauthServer = null;
119
- }
214
+ shutdownServer('OAuth server closed due to missing code.');
120
215
  return;
121
216
  }
122
217
  console.error('Received authorization code. Exchanging for token...');
@@ -124,14 +219,18 @@ export function startAuthorizationFlow() {
124
219
  try {
125
220
  // Exchange the authorization code for an access token
126
221
  const tokenResult = await oauthClient.getToken(tokenParams);
222
+ const newToken = tokenResult.token;
127
223
  console.error('Access Token received successfully!');
128
- accessToken = tokenResult.token.access_token;
129
- tokenData = tokenResult.token;
130
- // Persist token data to file
131
- if (tokenData) {
132
- await saveTokenToFile(tokenData);
133
- console.error('Token data has been persisted to file');
224
+ // Only treat the account as connected once Google Health recognises it
225
+ const linked = await verifyAccountLinked(newToken.access_token);
226
+ if (!linked) {
227
+ res.status(400).send(ERROR_MESSAGES.ACCOUNT_NOT_LINKED);
228
+ return;
134
229
  }
230
+ accessToken = newToken.access_token;
231
+ tokenData = newToken;
232
+ await saveTokenToFile(newToken);
233
+ console.error('Token data has been persisted to file');
135
234
  res.send('Authorization successful! You can close this window. The MCP Server is now authenticated.');
136
235
  }
137
236
  catch (error) {
@@ -142,7 +241,6 @@ export function startAuthorizationFlow() {
142
241
  try {
143
242
  const errorDetails = await typedError.response.text();
144
243
  console.error('Error details:', errorDetails);
145
- // Optionally parse errorDetails if it's JSON and log typedError.response.body
146
244
  }
147
245
  catch {
148
246
  console.error('Could not parse error response body.');
@@ -154,20 +252,14 @@ export function startAuthorizationFlow() {
154
252
  }
155
253
  finally {
156
254
  // Ensure the temporary server is always shut down after handling the callback
157
- if (oauthServer) {
158
- console.error('Shutting down temporary OAuth server...');
159
- oauthServer.close(() => {
160
- console.error('OAuth server closed.');
161
- oauthServer = null;
162
- });
163
- }
255
+ shutdownServer('OAuth server closed.');
164
256
  }
165
257
  });
166
258
  // Start the temporary local server
167
259
  oauthServer = app.listen(PORT, async () => {
168
260
  const authUrl = `http://localhost:${PORT}/auth`;
169
261
  console.error(`--------------------------------------------------------------------`);
170
- console.error(`ACTION REQUIRED: Fitbit Authorization Needed`);
262
+ console.error(`ACTION REQUIRED: Google Health Authorization Needed`);
171
263
  console.error(`Attempting to open authorization page in your browser:`);
172
264
  console.error(authUrl);
173
265
  console.error(`If the browser doesn't open, please navigate there manually.`);
@@ -189,41 +281,65 @@ export function startAuthorizationFlow() {
189
281
  });
190
282
  }
191
283
  /**
192
- * Retrieves the current Fitbit access token.
284
+ * Stops a pending authorization callback server (if any) and waits until its
285
+ * port is free, so a new flow can bind to it.
286
+ */
287
+ async function stopAuthorizationServer() {
288
+ const server = oauthServer;
289
+ if (!server)
290
+ return;
291
+ oauthServer = null;
292
+ await new Promise((resolve) => {
293
+ server.close(() => resolve());
294
+ // Drop keep-alive connections (e.g. an open browser tab) so close() completes
295
+ server.closeAllConnections?.();
296
+ });
297
+ }
298
+ /**
299
+ * Forgets the current token: clears it from memory and deletes the token file.
300
+ * @returns true when a token file was deleted, false when there was none.
301
+ */
302
+ export async function clearStoredToken() {
303
+ accessToken = null;
304
+ tokenData = null;
305
+ try {
306
+ await fs.unlink(TOKEN_FILE_PATH);
307
+ console.error(`Token file deleted: ${TOKEN_FILE_PATH}`);
308
+ return true;
309
+ }
310
+ catch (error) {
311
+ if (error.code === 'ENOENT')
312
+ return false;
313
+ throw error;
314
+ }
315
+ }
316
+ /**
317
+ * Clears the local tokens and starts a fresh authorization flow. Any flow that
318
+ * is still waiting for a callback is cancelled first. The new token is stored
319
+ * (in memory and on disk) once the user finishes consent in the browser.
320
+ * @returns whether a token file was removed, and the URL that starts consent
321
+ * (null when the flow could not start, e.g. missing client credentials).
322
+ */
323
+ export async function reauthenticate() {
324
+ await stopAuthorizationServer();
325
+ const tokenFileCleared = await clearStoredToken();
326
+ startAuthorizationFlow();
327
+ return {
328
+ tokenFileCleared,
329
+ authUrl: oauthServer ? `http://localhost:${PORT}/auth` : null,
330
+ };
331
+ }
332
+ /**
333
+ * Retrieves the current Google Health access token.
193
334
  * Automatically checks for token expiry and refreshes if necessary.
194
335
  * @returns The access token string or null if not available.
195
336
  */
196
337
  export async function getAccessToken() {
197
- // Return null if no token data exists
198
338
  if (!tokenData || !accessToken) {
199
- console.error('No valid access token found.'); // Added missing console.error and fixed surrounding logic
339
+ console.error('No valid access token found.');
200
340
  return null;
201
341
  }
202
- // Check if token is expired
203
- if (tokenData.expires_at && new Date(tokenData.expires_at) < new Date()) {
204
- console.error('Token is expired. Attempting to refresh...');
205
- try {
206
- // Create AccessToken instance from the current token data
207
- // FitbitTokenData is compatible with the 'Token' type expected by createToken
208
- const accessTokenObj = oauthClient.createToken(tokenData);
209
- // Refresh the token
210
- if (accessTokenObj.expired()) {
211
- const refreshedToken = await accessTokenObj.refresh();
212
- accessToken = refreshedToken.token.access_token;
213
- tokenData = refreshedToken.token;
214
- // Save the refreshed token
215
- await saveTokenToFile(tokenData);
216
- console.error('Token refreshed and saved successfully.');
217
- return accessToken;
218
- }
219
- }
220
- catch (refreshError) {
221
- console.error('Failed to refresh token:', refreshError);
222
- accessToken = null;
223
- tokenData = null;
224
- return null;
225
- }
226
- }
342
+ await refreshTokenIfNeeded();
227
343
  return accessToken;
228
344
  }
229
345
  /**
@@ -233,39 +349,15 @@ export async function getAccessToken() {
233
349
  export async function initializeAuth() {
234
350
  console.error('Auth initialized. Checking for persisted token...');
235
351
  try {
236
- // Load token data from file
237
352
  tokenData = await loadTokenFromFile();
238
353
  if (tokenData && tokenData.access_token) {
239
354
  accessToken = tokenData.access_token;
240
355
  console.error('Persisted access token loaded successfully.');
241
- // Check if token is expired and needs refresh
242
- if (tokenData.expires_at && new Date(tokenData.expires_at) < new Date()) {
243
- console.error('Token is expired. Attempting to refresh...');
244
- try {
245
- // Create AccessToken instance from the loaded token data
246
- // FitbitTokenData is compatible with the 'Token' type expected by createToken
247
- const accessTokenObj = oauthClient.createToken(tokenData);
248
- // Refresh the token
249
- if (accessTokenObj.expired()) {
250
- const refreshedToken = await accessTokenObj.refresh();
251
- accessToken = refreshedToken.token.access_token;
252
- tokenData = refreshedToken.token;
253
- // Save the refreshed token
254
- if (tokenData) {
255
- await saveTokenToFile(tokenData);
256
- console.error('Refreshed token saved successfully.');
257
- }
258
- }
259
- }
260
- catch (refreshError) {
261
- console.error('Error refreshing token:', refreshError);
262
- accessToken = null;
263
- tokenData = null;
264
- }
265
- }
356
+ await refreshTokenIfNeeded();
266
357
  }
267
358
  else {
268
359
  console.error('No valid access token found.');
360
+ tokenData = null;
269
361
  }
270
362
  }
271
363
  catch (error) {
@@ -1,4 +1,5 @@
1
- import { registerTool, CommonSchemas, handleFitbitApiCall } from './utils.js';
1
+ import { registerTool, CommonSchemas, dailyRollUp } from './utils.js';
2
+ import { VALIDATION_MESSAGES } from './config.js';
2
3
  /**
3
4
  * Registers an Active Zone Minutes time series tool with the MCP server.
4
5
  * @param server The McpServer instance.
@@ -7,16 +8,11 @@ import { registerTool, CommonSchemas, handleFitbitApiCall } from './utils.js';
7
8
  export function registerAzmTimeSeriesTool(server, getAccessTokenFn) {
8
9
  registerTool(server, {
9
10
  name: 'get_azm_timeseries',
10
- description: "Get the raw JSON response for Active Zone Minutes (AZM) time series data from Fitbit over a date range (max 1095 days). Returns total AZM plus breakdown by fat burn, cardio, and peak zones.",
11
+ description: `Get the raw JSON response for daily Active Zone Minutes (AZM) roll-ups from the Google Health API (dailyRollUp of "active-zone-minutes") over a civil date range. startDate is inclusive, endDate is exclusive. ${VALIDATION_MESSAGES.MAX_RANGE_90_DAYS}.`,
11
12
  parametersSchema: {
12
13
  startDate: CommonSchemas.startDate,
13
14
  endDate: CommonSchemas.endDate,
14
15
  },
15
- handler: async ({ startDate, endDate }) => {
16
- const endpoint = `activities/active-zone-minutes/date/${startDate}/${endDate}.json`;
17
- return handleFitbitApiCall(endpoint, { startDate, endDate }, getAccessTokenFn, {
18
- errorContext: `AZM data from ${startDate} to ${endDate}`
19
- });
20
- }
16
+ handler: async ({ startDate, endDate, }) => dailyRollUp('active-zone-minutes', getAccessTokenFn, { startDate, endDate }, `AZM data from ${startDate} to ${endDate}`),
21
17
  });
22
18
  }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Google Health API access helpers for the Fitbit-compatible output mode.
3
+ * Unlike the proxy tools these fetch *all* pages and return typed arrays, because the
4
+ * compat tools aggregate several calls into one Fitbit-shaped response.
5
+ */
6
+ import { makeGoogleHealthRequest, dataPointsPath, buildDailyRollUpBody, buildRangeFilter, createErrorResponse, GoogleHealthApiError, } from '../utils.js';
7
+ import { ERROR_MESSAGES } from '../config.js';
8
+ import { buildFitbitZones, rollupDate, zoneBoundsByDate, } from './convert.js';
9
+ // === Error handling ===
10
+ /** Runs a compat handler and converts failures into MCP error responses. */
11
+ export async function guard(context, fn) {
12
+ try {
13
+ return await fn();
14
+ }
15
+ catch (error) {
16
+ if (error instanceof GoogleHealthApiError) {
17
+ if (error.status === 400 && error.body.includes('ACCOUNT_NOT_LINKED')) {
18
+ return createErrorResponse(ERROR_MESSAGES.ACCOUNT_NOT_LINKED);
19
+ }
20
+ return createErrorResponse(`${ERROR_MESSAGES.API_REQUEST_FAILED} for ${context} (HTTP ${error.status}): ${error.body}`);
21
+ }
22
+ return createErrorResponse(`${ERROR_MESSAGES.API_REQUEST_FAILED} for ${context}: ${error instanceof Error ? error.message : String(error)}`);
23
+ }
24
+ }
25
+ async function request(path, getToken, options = {}) {
26
+ const result = await makeGoogleHealthRequest(path, getToken, options);
27
+ if (result === null)
28
+ throw new Error(ERROR_MESSAGES.NO_ACCESS_TOKEN);
29
+ return result;
30
+ }
31
+ // === Paging helpers ===
32
+ /** Lists every data point matching a filter, following nextPageToken. */
33
+ export async function listAll(dataType, getToken, params) {
34
+ const out = [];
35
+ let pageToken;
36
+ for (let page = 0; page < (params.maxPages ?? 20); page++) {
37
+ const res = await request(dataPointsPath(dataType), getToken, {
38
+ query: { filter: params.filter, pageSize: params.pageSize, pageToken },
39
+ });
40
+ out.push(...(res.dataPoints ?? []));
41
+ pageToken = res.nextPageToken;
42
+ if (!pageToken)
43
+ break;
44
+ }
45
+ return out;
46
+ }
47
+ /** Daily roll-up of a data type over [startDate, endDateExclusive), following nextPageToken. */
48
+ export async function rollUpAll(dataType, getToken, startDate, endDateExclusive, maxPages = 20) {
49
+ const out = [];
50
+ let pageToken;
51
+ for (let page = 0; page < maxPages; page++) {
52
+ const res = await request(dataPointsPath(dataType, ':dailyRollUp'), getToken, {
53
+ method: 'POST',
54
+ body: {
55
+ ...buildDailyRollUpBody(startDate, endDateExclusive),
56
+ ...(pageToken ? { pageToken } : {}),
57
+ },
58
+ });
59
+ out.push(...(res.rollupDataPoints ?? []));
60
+ pageToken = res.nextPageToken;
61
+ if (!pageToken)
62
+ break;
63
+ }
64
+ return out;
65
+ }
66
+ const CONTEXT_TTL_MS = 60 * 60 * 1000;
67
+ let cachedContext = null;
68
+ export function resetCompatCaches() {
69
+ cachedContext = null;
70
+ }
71
+ /** The account's time zone and display units, from Google Health settings (cached for an hour). */
72
+ export async function getUserContext(getToken) {
73
+ if (cachedContext && Date.now() - cachedContext.at < CONTEXT_TTL_MS) {
74
+ return cachedContext.value;
75
+ }
76
+ let value;
77
+ try {
78
+ const settings = await request('users/me/settings', getToken);
79
+ value = {
80
+ timeZone: process.env.COMPAT_TIME_ZONE || settings.timeZone || 'UTC',
81
+ weightUnit: settings.weightUnit,
82
+ distanceUnit: settings.distanceUnit,
83
+ waterUnit: settings.waterUnit,
84
+ };
85
+ }
86
+ catch (error) {
87
+ console.error('Could not load Google Health settings, using defaults:', error);
88
+ return { timeZone: process.env.COMPAT_TIME_ZONE || 'UTC' };
89
+ }
90
+ cachedContext = { value, at: Date.now() };
91
+ return value;
92
+ }
93
+ // === Heart-rate zones ===
94
+ /**
95
+ * Fitbit-style zones per civil date over [startDate, endDateExclusive), built from Google's own
96
+ * zone data: Active Zone Minutes / time-in-zone for minutes, calories-in-zone for kcal and
97
+ * daily-heart-rate-zones for the bpm bounds. Individual source failures degrade to missing
98
+ * fields (reported in `notes`); if every minutes source fails the first error is thrown.
99
+ */
100
+ export async function getZonesForRange(getToken, startDate, endDateExclusive) {
101
+ const [azm, tiz, kcal, bounds] = await Promise.allSettled([
102
+ rollUpAll('active-zone-minutes', getToken, startDate, endDateExclusive, 60),
103
+ rollUpAll('time-in-heart-rate-zone', getToken, startDate, endDateExclusive, 60),
104
+ rollUpAll('calories-in-heart-rate-zone', getToken, startDate, endDateExclusive, 60),
105
+ listAll('daily-heart-rate-zones', getToken, {
106
+ filter: buildRangeFilter('daily_heart_rate_zones.date', startDate, endDateExclusive),
107
+ pageSize: 10000,
108
+ }),
109
+ ]);
110
+ if (azm.status === 'rejected' && tiz.status === 'rejected')
111
+ throw azm.reason;
112
+ const notes = [];
113
+ const value = (r, what) => {
114
+ if (r.status === 'fulfilled')
115
+ return r.value;
116
+ notes.push(`${what} unavailable: ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`);
117
+ return [];
118
+ };
119
+ const byDate = (points) => new Map(points.flatMap((p) => (rollupDate(p) ? [[rollupDate(p), p]] : [])));
120
+ const azmByDate = byDate(value(azm, 'Active Zone Minutes'));
121
+ const tizByDate = byDate(value(tiz, 'time-in-heart-rate-zone'));
122
+ const kcalByDate = byDate(value(kcal, 'calories-in-heart-rate-zone'));
123
+ const boundsByDate = zoneBoundsByDate(value(bounds, 'daily-heart-rate-zones'));
124
+ const zones = new Map();
125
+ for (const date of new Set([...azmByDate.keys(), ...tizByDate.keys()])) {
126
+ const built = buildFitbitZones({
127
+ azm: azmByDate.get(date),
128
+ timeInZone: tizByDate.get(date),
129
+ calories: kcalByDate.get(date),
130
+ bounds: boundsByDate.get(date),
131
+ });
132
+ if (built)
133
+ zones.set(date, built);
134
+ }
135
+ return { zones, notes };
136
+ }