npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@cariva/vislife-health-sdk-rn

v0.1.3

Published

Cariva exercise SDK

Readme

@cariva/vislife-health-sdk-rn

React Native SDK for reading exercise and activity data from Android Health Connect and iOS HealthKit.

Published package: @cariva/vislife-health-sdk-rn

This release is a package move only. The JavaScript API, native module names, and React Native autolinking behavior stay the same.

Platform Support

| Platform | Status | |----------|--------| | Android | Supported (Health Connect) | | iOS | Supported (HealthKit) |


Requirements

  • React Native 0.68+ (New Architecture / Turbo Module compatible)
  • Android app minSdkVersion 24 or higher
  • iOS app deploymentTarget 13.0 or higher
  • A device or emulator with Health Connect (Android) or HealthKit (iOS) support
  • Health Connect installed on Android 13 and lower, or available through Play Services / system components on Android 14+

Architecture

The SDK now follows a Clean/Hexagonal structure:

  • android/.../domain: core models and algorithm services
  • android/.../application: use-cases and ports
  • android/.../infrastructure: Health Connect/Android adapters
  • android/.../HealthConnectModule.kt: thin React Native bridge/controller
  • src/domain, src/application, src/infrastructure: mirrored TypeScript boundary layers

See docs/architecture/clean-hexagonal-migration.md for migration details.


Installation

1. Install the package

# npm
npm install @cariva/vislife-health-sdk-rn

# yarn
yarn add @cariva/vislife-health-sdk-rn

2. Android setup

  • The library ships its own Health Connect permissions, package visibility queries, and permission rationale activities in the Android library manifest.
  • In a standard React Native app, Gradle manifest merge and autolinking pick these up automatically when you rebuild the app after installation.
  • On Android 13 and lower, install Health Connect before testing. On Android 14+, ensure the device has the required Play Services / system components available.
  • Minimum verification after install:
    1. Launch the rebuilt app.
    2. Call connect({ requestPermission: false, onMissingApp: 'status' }).
    3. If nextAction === 'GRANT_PERMISSIONS', call connect({ requestPermission: true, onMissingApp: 'status' }) to open the permission flow.

3. iOS setup

  • Install native dependencies after adding the package: cd ios && bundle exec pod install
  • Enable the HealthKit capability for your app target in Xcode.
  • Add NSHealthShareUsageDescription to Info.plist with the user-facing reason your app reads health data.
  • Build and run on a physical iPhone or iPad with Health data available.
  • Minimum verification after install:
    1. Launch the rebuilt app.
    2. Call connect({ requestPermission: true, onMissingApp: 'status' }).
    3. After the HealthKit prompt, call getHealthData(...) to verify effective read access.

Quick Start

import {
  connect,
  getHealthData,
  setAllowDatasource,
  getAllowDatasource,
  resetPermissions,
} from '@cariva/vislife-health-sdk-rn';

// 1. Connect and request permissions
const status = await connect({
  onMissingApp: 'status',
  requestPermission: true,
  permissionScope: ['steps', 'calories'],
});

if (!status.ready) {
  console.log('Next action required:', status.nextAction);
  // e.g. 'INSTALL_HEALTH_CONNECT' | 'GRANT_PERMISSIONS' | ...
  return;
}

// 2. Fetch exercise data
const data = await getHealthData({
  sinceMillis: Date.now() - 7 * 24 * 60 * 60 * 1000, // last 7 days
  untilMillis: Date.now(),
  bucketPeriod: 'daily',
  timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
  bmr: 1500,
});

console.log('Records:', data.records);
console.log('Active kcal:', data.summary.activeKcal);
console.log('Time zone:', data.meta.timeZone);

// 3. Start the platform reset/manage-permission flow, then re-check
const resetResult = await resetPermissions();
if (resetResult.userActionRequired) {
  // Show resetResult.message/instructions before asking the user to continue.
}
await connect({ requestPermission: false, onMissingApp: 'status' });

API Reference

connect(options?)

Checks Health Connect availability and optionally requests permissions.

type PermissionScope = 'steps' | 'activetimes' | 'calories' | 'distances';

type ConnectOptions = {
  onMissingApp?: 'status' | 'open';
  requestPermission?: boolean;
  permissionScope?: PermissionScope[];
};

type PermissionVerification = 'verified' | 'unverifiable' | 'not_checked';

type ConnectResult = {
  ready: boolean;
  healthConnectInstalled: boolean;
  healthConnectStatus: 'AVAILABLE' | 'UPDATE_REQUIRED' | 'UNAVAILABLE' | 'UNKNOWN';
  hasPermissions: boolean | 'unknown';
  permissionStatus: 'granted' | 'partial' | 'denied' | 'unknown';
  permissionVerification?: PermissionVerification;
  nextAction:
    | 'INSTALL_HEALTH_CONNECT'
    | 'UPDATE_HEALTH_CONNECT'
    | 'GRANT_PERMISSIONS'
    | 'OPEN_SETTINGS'
    | 'NONE';
  grantedPermissions?: string[];
  grantedScopes?: PermissionScope[];
  missingScopes?: PermissionScope[];
  errorCode?: 'USER_CANCELLED' | 'TIMEOUT' | 'NATIVE_ERROR' | 'CONNECT_IN_PROGRESS';
  errorMessage?: string;
};

| Option | Type | Default | Description | |--------|------|---------|-------------| | onMissingApp | 'status' \| 'open' | 'status' | 'open' opens Play Store for the missing app | | requestPermission | boolean | true | Trigger the Health Connect permission dialog | | permissionScope | PermissionScope[] | all scopes | Limit which permissions are requested |

Default behavior is split for backward compatibility:

  • connect() keeps the legacy interactive flow: it may open the Play Store when Health Connect needs installation/update and it requests permissions by default.
  • connect({ ... }) defaults missing option fields to onMissingApp: 'status', requestPermission: true, and all permission scopes.

For first-screen or app-start status checks, call:

const status = await connect({
  requestPermission: false,
  onMissingApp: 'status',
});

This never opens the Health Connect permission UI and never opens the Play Store. If permissions are missing on Android, it returns ready: false, hasPermissions: false, permissionVerification: 'verified', and nextAction: 'GRANT_PERMISSIONS'.

On iOS, HealthKit read permission is not reliably inspectable. iOS connect() reports hasPermissions: 'unknown', permissionStatus: 'unknown', and permissionVerification: 'unverifiable' for read-permission success/silent paths; consumers must verify effective read access with getHealthData() behavior in their UX.

activetimes may request both exercise-session and active-calories read permissions so the native fallback reader can still derive active duration when session records are unavailable.

On iOS, HealthKit does not allow apps to inspect read permission grants non-interactively. Use this flow to handle the permission recheck:

// Step 1: Silent status check (no HealthKit prompt)
const status = await connect({
  requestPermission: false,
  onMissingApp: 'status',
});

if (status.nextAction === 'GRANT_PERMISSIONS') {
  // Step 2: Request permission (shows HealthKit UI)
  const result = await connect({
    requestPermission: true,
    onMissingApp: 'status',
  });

  // Step 3: After no system error, verify via data read
  // iOS returns ready: true, hasPermissions: 'unknown' even if granted=false
  // because read grants cannot be verified programmatically.
  if (result.ready) {
    try {
      const data = await getHealthData({
        sinceMillis: Date.now() - 24 * 60 * 60 * 1000,
        untilMillis: Date.now(),
        bucketPeriod: 'daily',
        timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
        bmr: 1500,
      });
      // Data fetch succeeded - permissions are effective
    } catch (error) {
      // Handle authorization failure: guide user to resetPermissions()
      console.log('Authorization error:', error);
    }
  } else if (result.errorCode === 'NATIVE_ERROR' && result.nextAction === 'OPEN_SETTINGS') {
    // Native error requires Settings entry (e.g., authorizationDenied)
    await resetPermissions();
  }
}

iOS connect() result mapping:

| Input / Outcome | Prompt Behavior | ready | hasPermissions | permissionStatus | nextAction | Error Fields | |-----------------|-----------------|--------:|-----------------|------------------|--------------|--------------| | requestPermission: false and HealthKit available | No prompt | false | "unknown" | "unknown" | "GRANT_PERMISSIONS" | absent | | requestPermission: true with no native error | Prompt may appear | true | "unknown" | "unknown" | "NONE" | absent | | requestPermission: true with authorizationDenied | Prompt attempted; denied/system error | false | "unknown" | "unknown" | "OPEN_SETTINGS" | errorCode: "NATIVE_ERROR" | | requestPermission: true with authorizationNotRequested | Prompt attempted; no request | false | "unknown" | "unknown" | "GRANT_PERMISSIONS" | errorCode: "NATIVE_ERROR" | | requestPermission: true with other native error | Prompt/system error | false | "unknown" | "unknown" | "NONE" | errorCode: "NATIVE_ERROR" |


resetPermissions()

Starts a platform reset/manage-permission action.

type ResetPermissionsResult = {
  success: boolean;
  supported: boolean;
  platform?: 'android' | 'ios';
  resetMode?: 'PROGRAMMATIC_REVOKE' | 'OPEN_SETTINGS' | 'OPEN_HEALTH_APP' | 'UNAVAILABLE';
  didReset?: boolean;
  openedTarget?: 'HEALTH_CONNECT_SETTINGS' | 'APPLE_HEALTH_APP' | 'APP_SETTINGS' | 'NONE';
  verificationRequired?: boolean;
  requiresAppRestart?: boolean;
  nextAction?: 'RECHECK_PERMISSIONS' | 'OPEN_SETTINGS' | 'CHECK_ACCESS' | 'NONE';
  message?: string;
  userActionRequired?: boolean;
  attemptedTargets?: Array<'APPLE_HEALTH_APP' | 'APP_SETTINGS'>;
};

const status = await resetPermissions();

resetPermissions starts a platform-specific reset/manage-permission action. Android behavior is unchanged: it calls Health Connect's programmatic revoke path and returns didReset: true when the revoke request succeeds.

iOS cannot reset, revoke, grant, enable, or disable HealthKit permissions from the SDK. The iOS action is best-effort:

  • first attempt: open Apple Health with the unofficial x-apple-health:// scheme;
  • fallback: open the app's official Settings page with UIApplicationOpenSettingsURLString;
  • final fallback: resolve with resetMode: 'UNAVAILABLE', openedTarget: 'NONE', and manual Health app instructions.

Consumer apps must always show the returned iOS message/instructions when userActionRequired is true, ask the user to navigate to Health app > profile picture > Apps > [This App], then let the user return and tap Check Access. nextAction: 'CHECK_ACCESS' means call connect({ requestPermission: false, onMissingApp: 'status' }); use getHealthData() when actual read verification is required because HealthKit read grants are not fully introspectable.


getHealthData(options)

Reads normalized exercise data for a caller-selected absolute timestamp range.

type GetHealthDataOptions = {
  sinceMillis: number;
  untilMillis: number;
  bucketPeriod: 'hourly' | 'daily';
  timeZone: string;
  bmr: number;
};

type ValueProvenance =
  | 'platform_measured'
  | 'platform_inferred'
  | 'bmr_estimate'
  | 'total_minus_basal_estimate'
  | 'active_calorie_duration_estimate'
  | 'none';

type ReadOutcome = 'succeeded' | 'partial' | 'denied' | 'unknown';

type EmptyReason =
  | 'no_data'
  | 'permission_denied'
  | 'partial_permission'
  | 'filtered_out'
  | 'unknown';

type HealthDataBucket = {
  steps: number;
  totalCalories: number;
  totalCaloriesUnit: string;
  activeCalories: number;
  activeCaloriesUnit: string;
  activeCaloriesSource: 'device' | 'app' | 'manual' | 'unknown';
  provenance: {
    activeCalories: ValueProvenance;
    basalCalories: ValueProvenance;
    activeTime: ValueProvenance;
  };
  basalCalories: number;
  basalCaloriesUnit: string;
  distance: number;
  distanceUnit: string;
  activeTime: number;
  activeTimeUnit: string;
  startDate: string;
  endDate: string;
};

type HealthDataResult = {
  summary: {
    steps: number;
    activeKcal: number;
    distanceMeters: number;
    activeTimeMillis: number;
  };
  meta: {
    sinceMillis: number;
    untilMillis: number;
    timeZone: string;
    bucketPeriod: 'hourly' | 'daily';
    bucketCount: number;
    units: {
      calories: 'kcal';
      distance: 'km';
      activeTime: 'milliseconds';
    };
    readOutcome: ReadOutcome;
    emptyReason?: EmptyReason;
  };
  records: HealthDataBucket[];
  debug?: {
    raw?: {
      sampleCount: number;
      workoutCount: number;
      samples: Array<{
        metric: string;
        value: number;
        startDate: string;
        endDate: string;
        sourceName: string;
        sourceBundleId?: string | null;
        isManual: boolean;
      }>;
      workouts: Array<{
        startDate: string;
        endDate: string;
        sourceName: string;
        sourceBundleId?: string | null;
        isManual: boolean;
      }>;
    };
  };
};

Timestamp contract:

  • sinceMillis and untilMillis are Unix epoch milliseconds.
  • The range is [sinceMillis, untilMillis): sinceMillis is inclusive and untilMillis is exclusive.
  • untilMillis must be greater than sinceMillis.
  • Epoch seconds, NaN, Infinity, invalid timestamps, invalid time zones, invalid bmr, and default-mode ranges over 30 days are rejected.
  • An exact 30-day range is accepted.
  • timeZone is used only for bucket boundaries and date formatting; absolute timestamps are not reinterpreted as local time.

Periods longer than 30 days reject in default mode with error code E_INVALID_PERIOD.

Health Connect also applies runtime read quotas, with stricter limits for background work. The SDK keeps a short-lived exact-query cache for successful getHealthData() responses, so repeated identical refreshes within 15 seconds can reuse the last result instead of re-reading Health Connect.

To further reduce quota pressure, the Android reader now paces internal Health Connect sub-reads, including paginated reads and retry attempts, and performs one fixed 5-second cooldown retry when Health Connect reports a rate-limit error. If the retry still fails, the SDK surfaces the existing E_GET_EXERCISE_DATA error path.

To maximize cache hits and avoid unnecessary quota use, keep sinceMillis and untilMillis stable within a refresh cycle instead of shifting the window on every poll, and let your scheduler or caller back off repeated background sync attempts. A full changelog-based sync strategy is out of scope for this SDK patch.

Preferred response contract:

  • summary.activeKcal is the active-calorie total for the full query window.
  • summary.distanceMeters and summary.activeTimeMillis are full-window totals. Bucket distance remains kilometers for backward compatibility; summary distance is meters.
  • summary is always calculated from records[]: steps, active kcal, distance meters, and active time are sums of the emitted buckets.
  • meta.sinceMillis, meta.untilMillis, meta.timeZone, meta.bucketPeriod, meta.bucketCount, meta.units, meta.readOutcome, and optional meta.emptyReason describe the response shared by every bucket.
  • meta.bucketCount === records.length.
  • records is the hourly or daily bucket-by-bucket array returned for the requested bucketPeriod. Empty buckets are emitted with zero values.
  • records[*].provenance tells consumers whether activeCalories, basalCalories, and activeTime are measured, inferred, estimated, or absent for that bucket.
  • debug is an optional output-only field. When native returns it, the supported public shape is debug.raw with raw sample/workout counts and capped raw sample/workout previews. There is no debug request option.

Recommended field usage:

  • Use summary for totals across the full query window.
  • Use records for charts, timelines, and bucket-by-bucket UI.
  • Use meta for shared response metadata such as range echo fields, timeZone, bucketPeriod, bucketCount, units, readOutcome, and emptyReason.
  • Use bucket provenance for product labels around measured, inferred, estimated, or absent values. Summary totals can mix provenance across buckets; bucket provenance is authoritative.
  • Treat debug?.raw as optional diagnostics output; do not use it for product empty-result logic or require it for normal app flows.

setAllowDatasource(config)

Configures which data sources to exclude from results.

type DatasourceType = 'manual' | 'device' | 'app' | 'unknown';

type DatasourceConfig = {
  blacklist: DatasourceType[];
};

// Example: exclude manually-entered data
await setAllowDatasource({ blacklist: ['manual'] });

getAllowDatasource()

Returns the current datasource blacklist configuration.

const config = await getAllowDatasource();
// => { blacklist: ['manual'] }

Troubleshooting

Error code 5: HKErrorCodeAuthorizationDenied (iOS)

This error occurs when HealthKit authorization is denied. Common causes and solutions:

| Cause | Solution | |-------|----------| | Missing NSHealthShareUsageDescription in Info.plist | Add the key with a usage description string | | HealthKit capability not enabled | Enable HealthKit in Xcode project settings | | User previously denied permissions | Settings > Health > Data Access & Devices > [Your App] > enable permissions | | App not properly signed | Verify provisioning profile includes HealthKit entitlements |

connect() fails with an error

  • Android: Ensure Health Connect is installed (Android 13 and below) or available via Play Services (Android 14+)
  • iOS: Verify HealthKit is enabled in Xcode and the app is running on a physical device

Bucket Record Schema

Use data.records for per-bucket results:

type UnifiedIntervalRecord = {
  steps: number;
  activeCalories: number;
  activeCaloriesUnit: 'kcal';
  activeCaloriesSource: 'device' | 'app' | 'manual' | 'unknown';
  provenance: {
    activeCalories: ValueProvenance;
    basalCalories: ValueProvenance;
    activeTime: ValueProvenance;
  };
  basalCalories: number;
  basalCaloriesUnit: 'kcal';
  distance: number;
  distanceUnit: 'km';
  activeTime: number;
  activeTimeUnit: 'milliseconds';
  startDate: string; // ISO offset string, e.g. "2026-02-16T08:00:00+07:00"
  endDate: string;
};

records are grouped by the requested bucketPeriod. The first and last record may be partial buckets when sinceMillis or untilMillis land mid-hour or mid-day.

Bucket behavior:

  • Bucket boundaries are built from sinceMillis, untilMillis, bucketPeriod, and timeZone.
  • Platform queries use the absolute [sinceMillis, untilMillis) range.
  • Bucket boundaries use local time in the requested timeZone, including DST transitions; a local day can therefore have 23, 24, or 25 hourly buckets where the time zone observes DST.
  • A full-day Asia/Bangkok hourly range produces 24 buckets.
  • A 30-day hourly range produces about 720 buckets, subject to DST in the selected timeZone.
  • Records or platform aggregate intervals that cross request or bucket boundaries are clipped/split locally.
  • Data exactly at untilMillis is outside the range.

activeCaloriesSource classifies the datasource (device, app, manual, or unknown). It is not value provenance. Use record.provenance to distinguish measured, inferred, estimated, and absent values:

  • platform_measured: direct platform sample for the metric.
  • platform_inferred: inferred from a platform interval such as an exercise session or HealthKit workout.
  • bmr_estimate: calculated from caller-supplied bmr.
  • total_minus_basal_estimate: active calories estimated from total calories minus basal calories.
  • active_calorie_duration_estimate: active time estimated from active-calorie record duration.
  • none: no contributing source for that bucket field.

Empty or incomplete reads are represented on data.meta:

  • readOutcome: 'succeeded': the read completed for the platform-known scope.
  • readOutcome: 'partial': returned data may be incomplete, for example because permissions are partial or a platform query partially failed.
  • readOutcome: 'denied': Android detected missing Health Connect read permission.
  • readOutcome: 'unknown': the platform cannot safely distinguish the outcome, including ambiguous iOS HealthKit empty reads.
  • emptyReason is present only for effectively empty results and can be no_data, permission_denied, partial_permission, filtered_out, or unknown.

data.meta.units still exposes the shared units once per response:

{
  calories: 'kcal',
  distance: 'km',
  activeTime: 'milliseconds'
}

The current record shape still includes per-record unit fields and basalCalories for compatibility with existing consumers.

basalCalories reflects the bucketed basal energy for that same interval. In the current Phase 2 contract, callers provide bmr in kcal/day and should treat detailed estimation provenance as a later contract phase.

Example record:

{
  "steps": 1300,
  "activeCalories": 60.2,
  "activeCaloriesUnit": "kcal",
  "activeCaloriesSource": "device",
  "basalCalories": 42.5,
  "basalCaloriesUnit": "kcal",
  "distance": 1.0,
  "distanceUnit": "km",
  "activeTime": 1200000,
  "activeTimeUnit": "milliseconds",
  "startDate": "2026-02-16T08:00:00+07:00",
  "endDate": "2026-02-16T09:00:00+07:00"
}

Overlap Handling

The SDK automatically de-duplicates overlapping intervals before assigning active time to buckets. Overlapping intervals such as 09:00–10:00 plus 09:30–10:30 count as 90 minutes, not 120 minutes. Back-to-back intervals do not add overlap.

Default reads are aggregate/statistics-first so a 30-day hourly request does not raw-read every quantity sample or send raw platform records over the React Native bridge.

| Platform | Quantity metrics | Active time | Risk control | |----------|------------------|-------------|--------------| | Android Health Connect | Aggregate/bucket APIs for steps, distance, and active calories where possible. | Exercise session intervals are clipped, unioned, and split into SDK buckets. | Raw quantity reads are reserved for source-aware filtering. Summary is derived from emitted buckets only. | | iOS HealthKit | HKStatisticsCollectionQuery cumulative sums for stepCount, distanceWalkingRunning, and activeEnergyBurned. | HKWorkout intervals are clipped, unioned, and split into SDK buckets. | HealthKit read permission certainty is not claimed; ambiguous empty iOS reads remain readOutcome: 'unknown' and emptyReason: 'unknown'. |

Distance is returned on both platforms: bucket records[].distance is kilometers and summary.distanceMeters is meters. iOS distance comes from HealthKit distanceWalkingRunning.

If datasource/source filtering is configured, the SDK may switch to a slower source-aware path because aggregate/statistics APIs do not always expose enough source metadata. Source-aware raw-sample fallback can have platform-specific dedupe semantics; do not treat it as the performance baseline for normal unfiltered 30-day reads.


Calories Fallback (Total - Basal)

When direct active calories are unavailable or lower quality in some windows, the SDK can derive fallback active calories:

active = max(totalCalories - basalCalories, 0)

Platform Behavior:

Android (Health Connect):

  • Basal calories are calculated from BMR supplied via bmr parameter: bmr × (record duration / day)
  • If no total calories record exists, basal defaults to 0 (proportional to data coverage)

iOS (HealthKit):

  • Basal calories prefer actual HKQuantityTypeIdentifierBasalEnergyBurned data when available
  • Falls back to BMR calculation when no resting energy data exists: bmr × (bucket duration / day)
  • This provides more accurate basal values from Apple Watch measurements on iOS
  • Direct active-calorie measurement is preferred when present; fallback fills only uncovered windows.
  • Merge remains deterministic and avoids double counting across atomic windows.

Optional Debug Output

getHealthData() does not accept a debug request option. Some native debug builds may include optional data.debug.raw output:

type HealthDataDebug = {
  raw?: {
    sampleCount: number;
    workoutCount: number;
    samples: RawHealthDataSample[];
    workouts: RawHealthDataWorkout[];
  };
};

Treat debug?.raw as optional diagnostics output. Do not require it for normal app flows, and do not branch product UX on its presence.


Examples

Normal mode

import {
  connect,
  getHealthData,
  setAllowDatasource,
} from '@cariva/vislife-health-sdk-rn';

async function loadHealthData() {
  // Exclude manual entries
  await setAllowDatasource({ blacklist: ['manual'] });

  // Connect with minimal permission scope
  const status = await connect({
    onMissingApp: 'status',
    requestPermission: true,
    permissionScope: ['steps', 'calories'],
  });

  if (!status.ready) {
    console.warn('Health Connect not ready. Next action:', status.nextAction);
    return null;
  }

  // Query last 24 hours in hourly buckets
  const data = await getHealthData({
    sinceMillis: Date.now() - 24 * 60 * 60 * 1000,
    untilMillis: Date.now(),
    bucketPeriod: 'hourly',
    timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
    bmr: 1500,
  });

  console.log(data.summary.activeKcal);
  console.log(data.meta.readOutcome, data.meta.emptyReason);
  console.log(data.records[0]?.provenance);

  return data;
}

Silent status check

const status = await connect({
  requestPermission: false,
  onMissingApp: 'status',
});

if (status.nextAction === 'GRANT_PERMISSIONS') {
  // Show your own UI affordance, then call connect({ requestPermission: true }).
}

Optional debug output

const data = await getHealthData({
  sinceMillis: Date.now() - 24 * 60 * 60 * 1000,
  untilMillis: Date.now(),
  bucketPeriod: 'hourly',
  timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
  bmr: 1500,
});

console.log(data.debug?.raw?.sampleCount);
console.log(data.debug?.raw?.workoutCount);

Migration (2.0.0)

This release introduces a clean-break connect contract:

  • Removed ConnectResult.googleFitInstalled
  • Removed nextAction: 'INSTALL_GOOGLE_FIT'
  • connect readiness flow is now Health Connect-only

If you previously handled Google Fit branches, delete those checks and rely on:

  • INSTALL_HEALTH_CONNECT
  • UPDATE_HEALTH_CONNECT
  • GRANT_PERMISSIONS
  • NONE

Additive migration notes for getHealthData

  • Prefer summary + meta + records for new integrations.
  • summary.activeKcal is the explicit active-calorie total and corresponds to the legacy totalKcal.
  • includeEmptyBuckets: false now filters the projected records view only; totals still come from the unfiltered aggregation result.
  • records continue to use the existing UnifiedIntervalRecord schema in this release.
  • notices, legacy top-level totals, warnings, integrity, and diagnostics now appear only when debug: true is enabled.

Fixed

  • Double-count bug in generateDayToDayQueryRequests: Multi-day requests now split the range into consecutive non-overlapping ≤24h chunks. The previous implementation added all per-day chunks then unconditionally re-added the full original range, causing every day to be bucketed twice for multi-day queries.