feat: add Google Health API integration#1474
Conversation
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>
PR Validation ResultsChange Detection
|
There was a problem hiding this comment.
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.
| export const SyncBodySchema = z | ||
| .object({ | ||
| startDate: isoDate.optional(), | ||
| endDate: isoDate.optional(), | ||
| }) | ||
| .loose(); |
There was a problem hiding this comment.
| export const CallbackBodySchema = z | ||
| .object({ | ||
| code: z.string().min(1, 'code is required'), | ||
| }) | ||
| .loose(); |
There was a problem hiding this comment.
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'),
});| const grams = point.weight?.weightGrams; | ||
| if (grams === null) continue; | ||
| const weight = parseFloat(grams) / 1000; |
There was a problem hiding this comment.
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.
| 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; |
| 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; | ||
| } |
There was a problem hiding this comment.
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;
}| const pct = point.oxygenSaturation?.percentage; | ||
| if (pct === null) continue; |
There was a problem hiding this comment.
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).
| const pct = point.oxygenSaturation?.percentage; | |
| if (pct === null) continue; | |
| const pct = point.oxygenSaturation?.percentage; | |
| if (pct == null || isNaN(parseFloat(pct))) continue; |
| const minutesAsleep = parseInt(summary.minutesAsleep || 0, 10); | ||
| const minutesInPeriod = parseInt(summary.minutesInSleepPeriod || 0, 10); | ||
| const minutesToFall = parseInt(summary.minutesToFallAsleep || 0, 10); |
There was a problem hiding this comment.
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.
| 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; |
| } | ||
|
|
There was a problem hiding this comment.
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.
| } | |
| } | |
| if (!exerciseRecord) { | |
| log('error', 'Failed to find or create exercise record for ' + exerciseName); | |
| continue; | |
| } |
| await googleHealthService.syncGoogleHealthData( | ||
| userId, | ||
| 'manual', | ||
| startDate, | ||
| endDate | ||
| ); |
There was a problem hiding this comment.
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);
});| 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]); |
There was a problem hiding this comment.
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
- For consistency in integration callback components, use the
useToasthook from@/hooks/use-toastrather than the static import from@/components/ui/use-toast.
| onClick={(e) => { | ||
| e.preventDefault(); | ||
| navigator.clipboard.writeText( | ||
| `${window.location.origin}/googlehealth/callback` | ||
| ); | ||
| toast({ | ||
| title: 'Copied!', | ||
| description: 'Callback URL copied to clipboard.', | ||
| }); | ||
| }} |
There was a problem hiding this comment.
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',
});
}
}}
- 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>
apedley
left a comment
There was a problem hiding this comment.
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-anyeverywhere. I know it's an existing pattern but that's mostly due to an automatic migration from js to ts. Usingno-explicit-anyessentially 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]; |
There was a problem hiding this comment.
This is a bucketing error and will misattribute records that are near midnight as the wrong day.
There was a problem hiding this comment.
There was different iterations. Near midnight, taking the longest. Still testing on a large sample.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Did this sneak in from the other PR?
There was a problem hiding this comment.
Cleaned with Force Pull
ca5c7a7 to
d933962
Compare
- 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>
Test coverage addedTwo new test files in
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>
|
@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. |
There was a problem hiding this comment.
screenshots doesn't need to be attached under docs\screenshots. As you already attached them in the PR desc, that should be fine.
There was a problem hiding this comment.
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>
Round 2 fixes — f12714f, 2586d74Addressed everything from the automated review and @apedley's first-round comments. Gemini automated reviewAll critical and high-severity findings were fixed in an earlier commit (f4c57e3). This round adds the medium-severity ones that weren't already covered:
@apedley — UTC→local date bucketingThe
@apedley — Token revocation
@apedley —
|
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>
|
Removed the screenshots from Full review round status:
|
|
CI Test for Server still fails. could you look into it. |
|
Below files still has ANY declarations. 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>
Round 3 fixes — 2dac0a1Addressed 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 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 overrideAdded `SPARKY_FITNESS_GOOGLE_HEALTH_DATA_SOURCE` (mirrors `SPARKY_FITNESS_FITBIT_DATA_SOURCE`):
Anonymization: before any response is written to the diagnostic bundle, `anonymizeGoogleHealthData()` deep-clones the payload and:
All health metric values are left intact. |
|
pnpm run format & pnpm run validate still haunting you it seems 😃 |
|
i made one small tweak. merging it now. THanks a lot for your PR. this will be helpful to many. |
Tip
Help us review and merge your PR faster!
Please ensure you have completed the Checklist below.
For Frontend changes, please run
pnpm run validateto 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
googlehealthprovider 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
docker compose upPR Type
Checklist
All PRs:
New features only:
Frontend changes (
SparkyFitnessFrontend/):pnpm run validateand it passes.en) translation file.Backend changes (
SparkyFitnessServer/):rls_policies.sqlfor any new user-specific tables.UI changes (components, screens, pages):
Mobile changes (
SparkyFitnessMobile/):Screenshots
Click to expand
Exercises
Exercise Table
Sleep
Resting Heart Rate
HRV
SpO₂
Respiratory Rate
Skin Temperature Variation
Active Zone Minutes
Distance
Floors
Notes for Reviewers
SparkyFitnessServer/docs/externalapis.md.external_providerstable which already has RLS.