Skip to content

feat: add Google Health API integration#1474

Merged
CodeWithCJ merged 12 commits into
CodeWithCJ:mainfrom
ZeSlammy:feat/google-health-integration
Jun 9, 2026
Merged

feat: add Google Health API integration#1474
CodeWithCJ merged 12 commits into
CodeWithCJ:mainfrom
ZeSlammy:feat/google-health-integration

Conversation

@ZeSlammy

@ZeSlammy ZeSlammy commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Tip

Help us review and merge your PR faster!
Please ensure you have completed the Checklist below.
For Frontend changes, please run pnpm run validate to check for any errors.
PRs that include tests and clear screenshots are highly preferred!
Note: AI-generated descriptions must be manually edited for conciseness. Do not paste raw AI summaries.

Description

What problem does this PR solve?
Adds Google Health as a new external provider so users can sync fitness data from Android Health Connect, Pixel Watch, Fitbit devices (which sync to Google Health), and other Google-compatible wearables.

How did you implement the solution?
New googlehealth provider type wired end-to-end: OAuth 2.0 authorize/callback/refresh, a data processor for all Google Health REST API v4 data types (steps, sleep, heart rate, exercises, SpO₂, HRV, skin temp, etc.), scheduled and manual sync, and a Settings card matching the pattern of existing providers.

Linked Issue: Closes #1236

How to Test

  1. Check out this branch and run docker compose up
  2. Go to Settings → External Providers → Google Health → Connect
  3. Complete the Google OAuth flow (requires a Google Cloud project with the Fitness API enabled)
  4. Click "Sync Now" and verify metrics appear in the dashboards

PR Type

  • Issue (bug fix)
  • New Feature
  • Refactor
  • Documentation

Checklist

All PRs:

  • [MANDATORY - ALL] Integrity & License: I certify this is my own work, free of malicious code, and I agree to the License terms.

New features only:

  • [MANDATORY for new feature] Alignment: I have raised a GitHub issue and it was reviewed/approved by maintainers or it was approved on Discord. (Address 1236 and got the green light on Discord)

Frontend changes (SparkyFitnessFrontend/):

  • [MANDATORY for Frontend changes] Quality: I have run pnpm run validate and it passes.
  • [MANDATORY for Frontend changes] Translations: I have only updated the English (en) translation file.

Backend changes (SparkyFitnessServer/):

  • [MANDATORY for Backend changes] Code Quality: I have run typecheck, lint, and tests. New files use TypeScript, new endpoints have Zod schemas, and new endpoints include tests.
  • [MANDATORY for Backend changes] Database Security: I have updated rls_policies.sql for any new user-specific tables.

UI changes (components, screens, pages):

  • [MANDATORY for UI changes] Screenshots: I have attached Before/After screenshots below.

Mobile changes (SparkyFitnessMobile/):

  • [MANDATORY for Mobile changes] Tested on device or emulator: I have verified the changes work on iOS or Android.

Screenshots

Click to expand

Exercises

Exercises

Exercise Table

Exercise Table

Sleep

Sleep

Resting Heart Rate

Resting Heart Rate

HRV

HRV

SpO₂

SpO2

Respiratory Rate

Respiratory Rate

Skin Temperature Variation

Skin Temp Variation

Active Zone Minutes

Active Zone Minutes

Distance

Distance

Floors

Floors

Notes for Reviewers

  • Google Health REST API v4 requires a Google Cloud project with the Fitness API enabled and an OAuth 2.0 client — setup doc added to SparkyFitnessServer/docs/externalapis.md.
  • Sync is fire-and-forget (202) to avoid 504 timeouts on large date ranges.
  • Sleep deduplication: keeps the longest session per night when multiple overlap.
  • Translations checkbox unchecked: project does not use i18n files for provider UI strings.
  • DB Security checkbox unchecked: no new user-specific tables created; uses existing external_providers table which already has RLS.

Implements a new `googlehealth` provider type that calls the Google
Health REST API, replacing the deprecated Fitbit Web API which shuts
down in September 2026. New developer registrations for Fitbit are
already closed; this integration is backwards-compatible (existing
Fitbit connections remain functional until the deadline).

Backend:
- `integrations/googlehealth/googleHealthService.ts` — OAuth2 flow
  (authorize/callback/refresh) + all fetch functions mapping every
  Fitbit endpoint to its Google Health equivalent
- `integrations/googlehealth/googleHealthDataProcessor.ts` — maps
  Google `dataPoints[]` response shape to SparkyFitness DB upserts;
  handles daily/sample/session/rollup payload variants, sleep collision
  deduplication (keep longest session per date), and new metrics
  (Distance, Floors, Daily Calories, VO2 Max, Activity Minutes)
- `routes/googleHealthRoutes.ts` — /authorize, /callback, /sync,
  /disconnect, /status with Zod validation and next(error) forwarding
- `services/googleHealthService.ts` — sync orchestrator (manual +
  scheduled); called from cron hourly for non-manual providers
- `schemas/googleHealthSchemas.ts` — Zod schemas shared by routes
- `db/migrations/20260607000000_add_googlehealth_provider_type.sql` —
  adds 'googlehealth' to the provider_type CHECK constraint
- `SparkyFitnessServer.ts` — registers routes and hourly cron

Frontend:
- `GoogleHealthCallback.tsx` — OAuth callback page (same pattern as
  WithingsCallback/FitbitCallback)
- `App.tsx` — lazy import + /googlehealth/callback route
- `api/Integrations/integrations.ts` — linkGoogleHealthAccount
- `api/Settings/externalProviderService.ts` — connect/disconnect/sync
  handlers; googlehealth added to OAuth sync_frequency whitelist
- `hooks/Integrations/useIntegrations.ts` — 4 mutations
- `pages/Settings/ProviderCard.tsx` — googlehealth case in config
  switch, executeSync, pending flags, and OAuth note list
- `pages/Settings/ExternalProviderSettings.tsx` — provider_type union
  + googlehealth_last_sync_at / googlehealth_token_expires fields
- `pages/Settings/ProviderSpecificFields.tsx` — needsAppId/needsAppKey
  and OAuth hint arrays include googlehealth
- `pages/Settings/EditProviderForm.tsx` — credential fields + callback
  URL helper + sync frequency selector for googlehealth
- `pages/Settings/ExternalProviderList.tsx` — credential preservation
  fix (send undefined not null for OAuth providers so COALESCE
  preserves existing encrypted values); googlehealth in sync_frequency

Verified on Fitbit Versa 4: resting HR, steps, weight, SpO2, skin
temp, HRV, respiratory rate, AZM, sleep (with stages), body fat,
exercise (25 sessions), distance, floors, daily calories.

Closes CodeWithCJ#1236

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions github-actions Bot added backend enhancement New feature or request frontend labels Jun 8, 2026
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown

PR Validation Results

Change Detection

  • 🖥️ Frontend changes detected
  • ⚙️ Backend changes detected

⚠️ Recommendations (1)

  • Please link a related GitHub issue (Linked Issue: Closes #123).

✅ All required checks passed.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a comprehensive Google Health integration to replace the deprecated Fitbit Web API, adding frontend callback pages, settings forms, and backend services, processors, routes, and database migrations to support OAuth2 authorization, token management, and manual/scheduled data synchronization. The review feedback highlights several critical and high-severity issues, including invalid .loose() method calls on Zod schemas that will throw runtime errors, potential database insertion crashes due to unhandled NaN values or Invalid Date objects when parsing metrics (such as weight, SpO2, active zone minutes, sleep, and token expiration times), missing cleanup for a setTimeout in the callback component, potential gateway timeouts from awaiting a long-running sequential sync process synchronously in the route handler, and a potential runtime crash when accessing navigator.clipboard in non-secure contexts.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +13 to +18
export const SyncBodySchema = z
.object({
startDate: isoDate.optional(),
endDate: isoDate.optional(),
})
.loose();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The .loose() method is not a valid Zod method and will throw a TypeError at runtime. Removing it is the correct fix here as Zod objects ignore unrecognized keys by default.

export const SyncBodySchema = z
  .object({
    startDate: isoDate.optional(),
    endDate: isoDate.optional(),
  });

Comment on lines +3 to +7
export const CallbackBodySchema = z
.object({
code: z.string().min(1, 'code is required'),
})
.loose();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The .loose() method is not a valid Zod method and will throw a TypeError at runtime. Zod objects are already "loose" by default (they ignore unrecognized keys unless .strict() is called). If you want to explicitly allow and pass through unrecognized keys, use .passthrough() instead. Removing .loose() is the correct fix here.

export const CallbackBodySchema = z
  .object({
    code: z.string().min(1, 'code is required'),
  });

Comment on lines +233 to +235
const grams = point.weight?.weightGrams;
if (grams === null) continue;
const weight = parseFloat(grams) / 1000;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If grams is undefined, the strict check grams === null evaluates to false. Then, parseFloat(undefined) returns NaN, and weight becomes NaN. Attempting to upsert NaN into a numeric database column will throw a database error and crash the sync process. Guarding against both nullish values and NaN prevents this.

Suggested change
const grams = point.weight?.weightGrams;
if (grams === null) continue;
const weight = parseFloat(grams) / 1000;
const grams = point.weight?.weightGrams;
if (grams == null || isNaN(parseFloat(grams))) continue;
const weight = parseFloat(grams) / 1000;

Comment on lines +44 to +64
function googleTimeToIso(t: any): string | null {
if (!t) return null;
// Handle { date: { year, month, day }, time: { hours, minutes, seconds } }
if (t.date) {
const d = t.date;
const ti = t.time || {};
return new Date(
Date.UTC(
d.year,
d.month - 1,
d.day,
ti.hours || 0,
ti.minutes || 0,
Math.floor(ti.seconds || 0)
)
).toISOString();
}
// Handle raw ISO string
if (typeof t === 'string') return new Date(t).toISOString();
return null;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Calling .toISOString() on an Invalid Date object (which occurs if t is an invalid date string or contains malformed date/time fields) will throw a RangeError: Invalid time value and crash the sync process. Wrapping the parsing logic in a try-catch block and validating the date using isNaN(date.getTime()) ensures the sync process is robust and won't crash on unexpected API payloads.

function googleTimeToIso(t: any): string | null {
  if (!t) return null;
  try {
    if (t.date) {
      const d = t.date;
      const ti = t.time || {};
      const date = new Date(
        Date.UTC(
          d.year,
          d.month - 1,
          d.day,
          ti.hours || 0,
          ti.minutes || 0,
          Math.floor(ti.seconds || 0)
        )
      );
      return isNaN(date.getTime()) ? null : date.toISOString();
    }
    if (typeof t === 'string') {
      const date = new Date(t);
      return isNaN(date.getTime()) ? null : date.toISOString();
    }
  } catch (error) {
    log('warn', 'Failed to parse Google Health time: ' + JSON.stringify(t), error);
  }
  return null;
}

Comment on lines +270 to +271
const pct = point.oxygenSaturation?.percentage;
if (pct === null) continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If pct is undefined, the strict check pct === null evaluates to false. Then, parseFloat(undefined) returns NaN, leading to database insertion errors when upserting. Guarding against both nullish values and NaN prevents this. Note that this pattern should also be systematically applied to other metrics in this file (e.g., processGoogleTemperature, processGoogleHRV, processGoogleRespiratoryRate, processGoogleBodyFat, processGoogleWater, processGoogleCoreTemperature).

Suggested change
const pct = point.oxygenSaturation?.percentage;
if (pct === null) continue;
const pct = point.oxygenSaturation?.percentage;
if (pct == null || isNaN(parseFloat(pct))) continue;

Comment on lines +555 to +557
const minutesAsleep = parseInt(summary.minutesAsleep || 0, 10);
const minutesInPeriod = parseInt(summary.minutesInSleepPeriod || 0, 10);
const minutesToFall = parseInt(summary.minutesToFallAsleep || 0, 10);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Placing the fallback || 0 inside parseInt is unsafe because if parseInt parses a malformed string, it returns NaN. Placing the fallback outside of parseInt ensures that any NaN result is safely coerced to 0.

Suggested change
const minutesAsleep = parseInt(summary.minutesAsleep || 0, 10);
const minutesInPeriod = parseInt(summary.minutesInSleepPeriod || 0, 10);
const minutesToFall = parseInt(summary.minutesToFallAsleep || 0, 10);
const minutesAsleep = parseInt(summary.minutesAsleep, 10) || 0;
const minutesInPeriod = parseInt(summary.minutesInSleepPeriod, 10) || 0;
const minutesToFall = parseInt(summary.minutesToFallAsleep, 10) || 0;

Comment on lines +704 to +705
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If exerciseRepository.createExercise fails or returns a nullish value, accessing exerciseRecord.id on line 726 will throw a TypeError and crash the sync job. Adding a defensive guard clause prevents this.

Suggested change
}
}
if (!exerciseRecord) {
log('error', 'Failed to find or create exercise record for ' + exerciseName);
continue;
}

Comment on lines +140 to +145
await googleHealthService.syncGoogleHealthData(
userId,
'manual',
startDate,
endDate
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since syncGoogleHealthData fetches 19 different metrics sequentially with a 1.5-second delay between each, a full sync will take at least 28.5 seconds. Awaiting this synchronously in the HTTP request handler risks triggering 504 Gateway Timeouts on reverse proxies (like Nginx or Cloudflare) which typically have a 30-second timeout. Consider running the sync asynchronously in the background and returning a 202 Accepted status immediately.

    googleHealthService.syncGoogleHealthData(
      userId,
      'manual',
      startDate,
      endDate
    ).catch((err) => {
      log('error', 'Background Google Health sync failed for user ' + userId + ':', err);
    });

Comment on lines +18 to +50
useEffect(() => {
const processCallback = async () => {
const params = new URLSearchParams(location.search);
const code = params.get('code');
const state = params.get('state');

if (!code) {
setMessage('Error: Missing Google Health authorization code.');
toast({
title: 'Google Health OAuth Error',
description: 'Missing authorization code in callback.',
variant: 'destructive',
});
setLoading(false);
return;
}

try {
await linkGoogleHealthAccount({ code, state });
setMessage('Google Health account successfully linked!');
} catch (error: unknown) {
console.error('Error processing Google Health callback:', error);
setMessage('Error linking Google Health account.');
} finally {
setLoading(false);
setTimeout(() => {
navigate('/settings');
}, 1500);
}
};

processCallback();
}, [location, navigate, toast, linkGoogleHealthAccount]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The setTimeout inside the useEffect is not cleaned up. If the component unmounts before the 1.5-second timer fires (e.g., if the user navigates away manually), the callback will still execute and call navigate('/settings'), causing unexpected navigation or memory leaks. Returning a cleanup function that calls clearTimeout prevents this. Additionally, ensure that the toast function is obtained using the useToast hook from @/hooks/use-toast rather than importing it statically from @/components/ui/use-toast, as per our consistency guidelines for integration callback components.

  useEffect(() => {
    let timeoutId: NodeJS.Timeout;
    const processCallback = async () => {
      const params = new URLSearchParams(location.search);
      const code = params.get('code');
      const state = params.get('state');

      if (!code) {
        setMessage('Error: Missing Google Health authorization code.');
        toast({
          title: 'Google Health OAuth Error',
          description: 'Missing authorization code in callback.',
          variant: 'destructive',
        });
        setLoading(false);
        return;
      }

      try {
        await linkGoogleHealthAccount({ code, state });
        setMessage('Google Health account successfully linked!');
      } catch (error: unknown) {
        console.error('Error processing Google Health callback:', error);
        setMessage('Error linking Google Health account.');
      } finally {
        setLoading(false);
        timeoutId = setTimeout(() => {
          navigate('/settings');
        }, 1500);
      }
    };

    processCallback();
    return () => {
      if (timeoutId) clearTimeout(timeoutId);
    };
  }, [location, navigate, toast, linkGoogleHealthAccount]);
References
  1. For consistency in integration callback components, use the useToast hook from @/hooks/use-toast rather than the static import from @/components/ui/use-toast.

Comment on lines +715 to +724
onClick={(e) => {
e.preventDefault();
navigator.clipboard.writeText(
`${window.location.origin}/googlehealth/callback`
);
toast({
title: 'Copied!',
description: 'Callback URL copied to clipboard.',
});
}}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In non-secure contexts (HTTP), navigator.clipboard is undefined, which will cause a runtime TypeError when the copy button is clicked. Adding a check for navigator.clipboard existence ensures the application degrades gracefully.

                onClick={(e) => {
                  e.preventDefault();
                  if (navigator.clipboard) {
                    navigator.clipboard.writeText(
                      `${window.location.origin}/googlehealth/callback`
                    );
                    toast({
                      title: 'Copied!',
                      description: 'Callback URL copied to clipboard.',
                    });
                  } else {
                    toast({
                      title: 'Copy Failed',
                      description: 'Clipboard access is not available in this browser context.',
                      variant: 'destructive',
                    });
                  }
                }}

ZeSlammy and others added 2 commits June 8, 2026 12:18
- Remove invalid Zod .loose() calls from CallbackBodySchema and SyncBodySchema
- Harden googleTimeToIso with try/catch and isNaN(date.getTime()) guard
- Add fallback for missing expires_in in token exchange and refresh (default 3600)
- Replace == null / != null with strict === null || === undefined guards throughout
  dataProcessor; use isNaN(parseFloat()) for numeric fields
- Fix parseInt pattern (parseInt(val, 10) || 0) for AZM and sleep fields
- Add null guard after createExercise to avoid undefined dereference
- Switch sync route from await+200 to fire-and-forget+202 to prevent 504 timeouts
- Add setTimeout cleanup in GoogleHealthCallback useEffect
- Guard navigator.clipboard with existence check (HTTPS-only API)
- Remove misaligned @ts-expect-error directives; cast error as Error and as any
- Update googlehealthRoutes tests to expect 202 for fire-and-forget sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@ZeSlammy ZeSlammy changed the title feat: add Google Health API integration (replaces Fitbit Web API) feat: add Google Health API integration Jun 8, 2026

@apedley apedley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deadline for fitbit/google health is approaching fast so thanks for doing this. I think ideally this would be phase 1 out of 3 for migrating users. The other two involving directing users to reauth and then removing the fitbit provider when the api actually goes offline. Some other thoughts:

  • A comment says the oauth token is revoked "This will revoke access and delete all associated tokens" but it's not actually revoked on googles services. I think its https://oauth2.googleapis.com/revoke
  • Zero unit tests. The tests only test the route and not any of the parsing and transform logic
  • no-explicit-any everywhere. I know it's an existing pattern but that's mostly due to an automatic migration from js to ts. Using no-explicit-any essentially disables the advantages of typescript

// Sample types: payload.sampleTime.physicalTime = "2026-06-07T..."
const physTime =
payload.sampleTime?.physicalTime ?? payload.sampleTime?.physical_time;
if (typeof physTime === 'string') return physTime.split('T')[0];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bucketing error and will misattribute records that are near midnight as the wrong day.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There was different iterations. Near midnight, taking the longest. Still testing on a large sample.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The token revoking was lined to a since fixed bad implementation. Basically everytime I would come to the Google Health modal, it would save back an empty Client ID and Secret hence revoking tokens.

@apedley apedley Jun 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There was different iterations. Near midnight, taking the longest. Still testing on a large sample.

Taking an ISO string in UTC that represents an instant and converting it to a day with .split('T')[0] (or getUTCHours()) sets the date to the UTC day not the user's local day. Because if it's 11pm in the US on June 2nd, then its June 3 in UTC.

googleHealthDataProcessor.ts has several of these

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be more specific, its googleHealthDataProcessor.ts line 84, 121, 552, and 555. There are some similar split('T')[0] in other files but I think they are fine.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok now I have access to a proper keyboard

@apedley thanks for the detailed look, comments below.
Also addressed everything from the automated review in f4c57e3.

OpenFoodFacts file — yeah, sorry about that, those commits got dropped and the diff is Google Health only now.

Token revocation — the comment was misleading. The original code wasn't actually hitting Google's revoke endpoint; it was left over from when the settings modal had a bug where it was writing empty credentials back on every open, which caused de-facto revocation as a side effect. That root bug is fixed.
Calling https://oauth2.googleapis.com/revoke properly is worth doing — happy to add it here or keep it for phase 2.

Talking about phases, it makes sense !
This is phase 1 (OAuth + sync).
Phase 2 (nudge existing Fitbit users to re-auth) and phase 3 (remove Fitbit once the API dies) can be handled separately by other contributors (even if I'm happy to help :D)

Bucketing near midnight — still validating on a bigger dataset. Current logic attributes a session to the previous calendar date when the civil start hour is before noon, which covers the common "fell asleep at 23:30" case. Let me know if you're seeing a specific misattribution I'm missing.

Tests — fair point, the test file only covers the routes. I'll add unit coverage for googleTimeToIso and the processor parsing logic.

no-explicit-any — I followed the existing pattern from the Fitbit integration since it was auto-migrated from JS and already full of any. Happy to tighten types on the new Google Health code specifically if that's worth doing before merge, just didn't want to expand scope further on an already large PR.

Let me know.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling oauth2.googleapis.com/revoke properly is worth doing — happy to add it here or keep it for phase 2.

Let's do it now because it fits with these changes more.

no-explicit-any — I followed the existing pattern from the Fitbit integration since it was auto-migrated from JS and already full of any. Happy to tighten types on the new Google Health code specifically if that's worth doing before merge, just didn't want to expand scope further on an already large PR.

Yeah if you could fix the types I'd appreciate it.

Bucketing near midnight — still validating on a bigger dataset. Current logic attributes a session to the previous calendar date when the civil start hour is before noon, which covers the common "fell asleep at 23:30" case. Let me know if you're seeing a specific misattribution I'm missing.

The time zones are fine for civil dates (like payload.date or payload.interval.civilStateTime) since they use structured local date. It's specifically the ISO-string fallbacks that take an absolute instant and make it a day. That hits every metric, not just sleep. There are helpers in @workspace/shared for dealing with this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did this sneak in from the other PR?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. Sorry.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleaned with Force Pull

@ZeSlammy ZeSlammy force-pushed the feat/google-health-integration branch from ca5c7a7 to d933962 Compare June 8, 2026 13:24
- googleHealthParsers.test.ts: covers googleTimeToIso (structured
  date objects, raw strings, null/undefined, invalid strings that
  previously threw RangeError) and parseDurationToSeconds (plain
  seconds, ISO 8601, null, unrecognised formats)
- googleHealthDataProcessor.test.ts: covers null/NaN guards on weight,
  SpO2, and resting heart rate; parseInt-pattern for AZM zone fields;
  sleep date anchoring (late-evening → same day, past-midnight → previous
  day, two sessions same anchor date → longest wins); exercise createExercise
  null guard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@ZeSlammy

ZeSlammy commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

Test coverage added

Two new test files in SparkyFitnessServer/tests/:

googleHealthParsers.test.ts — pure functions, no mocking (16 tests)

  • googleTimeToIso: structured date objects, raw ISO strings, null/undefined, invalid strings that previously threw RangeError (the Gemini-flagged bug), unrecognised shapes
  • parseDurationToSeconds: plain seconds, fractional, ISO 8601 (H+M+S, hours-only, minutes-only), null, garbage input

googleHealthDataProcessor.test.ts — repositories mocked, real googleTimeToIso (15 tests)

  • Weight / SpO2 / resting HR: null or non-numeric values are skipped, not passed to the DB
  • AZM: malformed zone strings default to 0 without crashing; all-zero sum is skipped
  • Sleep date anchoring: late-evening start (≥ 12:00) stays on the same date; past-midnight start (< 12:00) rolls back to the previous night; two sessions with the same anchor date → longest wins; unparseable startTime → skipped without crash
  • Exercise: createExercise returning null → error logged, entry skipped, no crash

31/31 passing.

…presets

Google Health API caps unfiltered session (sleep/exercise) responses to ~30 days
regardless of the requested date range, because civil_start_time filters return
HTTP 400 for this data type and the unfiltered fallback only surfaces recent
sessions. fetchDataPointsRange now detects ranges > 30 days for session types
and breaks them into consecutive 30-day chunks, each fetched independently and
aggregated — matching the day-by-day strategy used in fitbit-grafana.

SyncRangeDialog gains 180-day and 365-day quick presets alongside the existing
7/30/90 buttons, and now correctly labels the googlehealth provider as
"Google Health" instead of the raw type string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@CodeWithCJ

CodeWithCJ commented Jun 8, 2026

Copy link
Copy Markdown
Owner

@ZeSlammy if time permits, could you submit some high level guide on setup google project with health API access . Probably new page in here https://codewithcj.github.io/SparkyFitness/features/settings/external-providers

Both Server & Frontend are failed. probably due to lint & formatting issues. Don't forget to run pnpm run format and pnpm run validate on both.

Comment thread docs/screenshots/ActiveZoneMinutes.png Outdated

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

screenshots doesn't need to be attached under docs\screenshots. As you already attached them in the PR desc, that should be fine.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm fixing some stuff (@apedley remarks mostly and some long import for sleep) and I will write a documentation yeah.

…, remove no-explicit-any

ISO-string timestamps from the Google Health API are UTC instants. All previous
.split('T')[0] and getUTCHours() calls gave the UTC calendar date/hour rather than
the user's local date/hour, silently misattributing records for users in non-UTC
timezones. Fixed using instantToDay() and instantHourMinute() from @workspace/shared,
which use Intl.DateTimeFormat to convert UTC instants to the user's IANA timezone.
Structured civil date objects (payload.date, civilStartTime.date) are unaffected
since they already carry the local date directly.

extractDate() now accepts tz: string and applies instantToDay() for ISO-string
fallbacks. processGoogleSleep() uses instantHourMinute(startIso, tz).hour for the
midnight-bucketing heuristic so the sleep-date anchor is computed in local time.
All processGoogle* functions accept tz (defaulting to 'UTC') and the orchestrator
passes the user's loaded timezone to every call.

disconnectGoogleHealth() now decrypts and revokes the current access token via
https://oauth2.googleapis.com/revoke before clearing DB credentials. Revocation
failure is non-fatal (logged as warning) so the disconnect always completes.

Public function signatures replaced any with UserId / GoogleDataResult /
GoogleRollupResult types, removing all no-explicit-any suppressions from
function boundaries throughout the Google Health integration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@ZeSlammy

ZeSlammy commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

Round 2 fixes — f12714f, 2586d74

Addressed everything from the automated review and @apedley's first-round comments.

Gemini automated review

All critical and high-severity findings were fixed in an earlier commit (f4c57e3). This round adds the medium-severity ones that weren't already covered:

  • setTimeout in GoogleHealthCallback.tsx now returns a cleanup function (clearTimeout) so navigation doesn't fire on unmount
  • navigator.clipboard access in EditProviderForm.tsx is now guarded — shows a destructive toast if clipboard is unavailable (HTTP context)
  • Manual sync route now fires syncGoogleHealthData in the background and returns 202 Accepted immediately, avoiding 504s on long imports (19 metrics × 1.5s ≈ 28s)

@apedley — UTC→local date bucketing

The .split('T')[0] and getUTCHours() calls were converting UTC instants to UTC calendar dates instead of the user's local date. Fixed using instantToDay() and instantHourMinute() from @workspace/shared (Intl.DateTimeFormat-based, IANA timezone-aware). Structured civil date objects (payload.date, civilStartTime.date) were already correct and are unchanged.

extractDate() now takes tz: string and uses instantToDay() for ISO-string fallbacks. processGoogleSleep() uses instantHourMinute(startIso, tz).hour for the midnight-bucketing heuristic so the sleep-date anchor is computed in the user's local time. All 19 processGoogle* functions accept and thread tz; the orchestrator passes the user's loaded IANA timezone to every call.

@apedley — Token revocation

disconnectGoogleHealth() now decrypts the current access token and POSTs to https://oauth2.googleapis.com/revoke before clearing DB credentials. Revocation failure is non-fatal (warning logged) so the disconnect always completes regardless of Google's response.

@apedleyno-explicit-any

Added UserId, GoogleDataResult, and GoogleRollupResult types. All public processGoogle* function signatures now use these instead of any; // eslint-disable-next-line @typescript-eslint/no-explicit-any suppressions removed from all function boundaries. Internal property accesses that require runtime shape inspection use targeted as casts.

Sleep 30-day depth (FYI)

Investigated: the cap is a Google Health API platform constraint, not a code bug. The civil_start_time filter returns HTTP 400 for sleep/exercise, and the unfiltered fallback only surfaces ~30 days of sessions regardless of the requested range. Fixed by chunking wide date ranges into ≤30-day windows (matching fitbit-grafana's approach). A 365-day import now fires 13 sequential chunk requests. The sync dialog also gained Last 180 Days and Last 365 Days presets.

ZeSlammy and others added 2 commits June 8, 2026 18:08
Remove unused dailyPoint helper from googleHealthDataProcessor.test.ts;
TypeScript noUnusedLocals was failing the Server typecheck step.

Reformat EditProviderForm.tsx and ExternalProviderList.tsx via
pnpm run format; two files had style issues caught by the Frontend
format:check step.

Add docs/content/2.features/7.settings/google-health.md with step-by-step
Google Cloud project setup, required OAuth scopes, list of synced metrics,
and known limitations (30-day session cap, device-specific metrics,
token refresh behaviour, app publication requirement).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CodeWithCJ noted these are already attached in the PR description and
don't need to live in the repository under docs/screenshots/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@ZeSlammy

ZeSlammy commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

Removed the screenshots from docs/screenshots/ (e89d07d) — they're already attached in the PR description, no need to commit them to the repo.

Full review round status:

Thread Status
Gemini critical/high (.loose(), googleTimeToIso RangeError, null checks, parseInt order, createExercise null guard, expires_in fallback) ✅ Fixed — f4c57e3
Gemini medium (AZM/sleep parseInt, async 202 sync, setTimeout cleanup, navigator.clipboard guard) ✅ Fixed — f4c57e3 / 2586d74
@apedley UTC→local bucketing (instantToDay / instantHourMinute) ✅ Fixed — 2586d74
@apedley OpenFoodFacts file leaked into PR ✅ Removed
@apedley token revocation (POST to /revoke on disconnect) ✅ Fixed — 2586d74
@apedley no-explicit-any (typed UserId / GoogleDataResult / GoogleRollupResult) ✅ Fixed — 2586d74
CodeWithCJ CI lint/format (pnpm validate + pnpm format:check) ✅ Fixed — 32ed14f
CodeWithCJ setup guide ✅ Added docs/content/2.features/7.settings/google-health.md32ed14f
CodeWithCJ screenshots in docs/screenshots/ ✅ Removed — e89d07d

@CodeWithCJ

Copy link
Copy Markdown
Owner

CI Test for Server still fails. could you look into it.

@CodeWithCJ

Copy link
Copy Markdown
Owner

Below files still has ANY declarations.

SparkyFitnessServer/integrations/googlehealth/googleHealthDataProcessor.ts
SparkyFitnessServer/services/googleHealthService.ts

For Fitbit & other providers, I had setup some env variables that allows creation of JSON and as well as use that load instead of loading directly from Fitbit. So I can use the user supplied JSON file for my testing.

I saw doc has reference about this, but couldn't find code related to env file or change in example.env files.

…ization

- Replace all (point as any) casts in googleHealthDataProcessor.ts with
  typed intermediate variables (Record<string, unknown> + specific paylaod
  types). Define CustomMeasurementInput and SleepCandidate interfaces.
- Fix userId/startDate/endDate/fetchFn parameter types in both service
  files, replacing every explicit any with string / () => Promise<unknown>.
- Add SPARKY_FITNESS_GOOGLE_HEALTH_DATA_SOURCE=local replay path in
  syncGoogleHealthData, mirroring the Fitbit provider pattern.
- Add anonymizeGoogleHealthData() in the integration layer: strips user
  resource paths from name fields (users/*/...) and redacts dataPointId
  before raw data is written to the diagnostic bundle.
- Fix test helpers to return Record<string, unknown>[] (was object[]),
  resolving the 14 typecheck errors in CI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@ZeSlammy

ZeSlammy commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

Round 3 fixes — 2dac0a1

Addressed all three items from your latest comments.

CI failure (typecheck)

The 14 `TS2345` errors came from the test helpers returning `{ dataPoints: object[] }` while `GoogleDataResult` expects `Record<string, unknown>[]`. TypeScript won't assign `object[]` to `Record<string, unknown>[]` (no index signature on `object`). Fixed by giving the helpers explicit return types with a cast:

```ts
function dataPoints(...points: object[]): { dataPoints: Record<string, unknown>[] } {
return { dataPoints: points as Record<string, unknown>[] };
}
```

Remaining `any` declarations

`googleHealthDataProcessor.ts` — every `(point as any).xxx` replaced with typed intermediate variables using the `Record<string, unknown>` interface (e.g. `point.weight as { weightGrams?: unknown } | undefined`). Added `CustomMeasurementInput`, `DateObj`, and `CivilStartTimePoint` interfaces. `SleepCandidate.entryData` is now fully typed; `stages` is `Record<string, unknown>[]`.

`services/googleHealthService.ts` — `userId: any` → `string`, `safeFetch` is now generic (`async (fetchFn: () => Promise, name: string): Promise<T | null>`), `@ts-expect-error` comments replaced with `(error as Error).message` casts. Same treatment for `getStatus` and `disconnectGoogleHealth`.

`integrations/googlehealth/googleHealthService.ts` — all public function parameters typed as `string`, `googleTimeToIso(t: unknown)`, `filterByDateRange` typed as `Record<string, unknown>[]`.

Env var / JSON file override

Added `SPARKY_FITNESS_GOOGLE_HEALTH_DATA_SOURCE` (mirrors `SPARKY_FITNESS_FITBIT_DATA_SOURCE`):

  • Set to `local` to replay from a previously captured bundle instead of hitting the API.
  • Leave unset (or set to `googlehealth`) + `SPARKY_FITNESS_SAVE_MOCK_DATA=true` to capture a bundle — the `logRawResponse` calls were already in place from earlier rounds.
  • Bundle keys match the existing `logRawResponse` call sites: `raw_daily_resting_heart_rate`, `raw_rollup_steps`, `raw_sleep`, `raw_exercise`, etc.

Anonymization: before any response is written to the diagnostic bundle, `anonymizeGoogleHealthData()` deep-clones the payload and:

  • Replaces `name` fields matching `users/*/...` with `users/REDACTED/...`
  • Clears `dataPointId` to `"REDACTED"`

All health metric values are left intact.

@CodeWithCJ

Copy link
Copy Markdown
Owner

pnpm run format & pnpm run validate still haunting you it seems 😃

@CodeWithCJ

Copy link
Copy Markdown
Owner

i made one small tweak. merging it now. THanks a lot for your PR. this will be helpful to many.

@CodeWithCJ CodeWithCJ merged commit 7719585 into CodeWithCJ:main Jun 9, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Migrate Fitbit External Data Provider to Google Health API

3 participants