Google Health API Connection - Implementation Plan

Status: Planning artifact Primary reference: docs/google-health/project-plan.md Goal: Turn the Google Health project plan into a practical, file-by-file implementation sequence for Lifehub.


Implementation Principles

  • Keep the integration in a first-class Health domain instead of forcing all provider data into ActivityLog.
  • Use the real Google Health API at https://health.googleapis.com/v4 as the only v1 provider.
  • Call the REST API directly with Net::HTTP โ€” the google-apis-health_v4 generated client is not published on rubygems, so we use only the real googleauth gem for OAuth2 token refresh.
  • Keep Google login OAuth separate from Google Health authorization.
  • Store raw provider payloads for traceability, but make Lifehub screens read normalized records.
  • Make every sync idempotent. A failed or repeated job must not duplicate activity logs, habit completions, or XP.
  • Keep records user-owned with belongs_to :user.
  • Do not create app/services/. Use namespaced model classes under app/models/health/.

Release Shape

Build this as three releases instead of one large merge.

  1. Foundation release: gems, credentials, database tables, models, policies, and a connection screen.
  2. Sync release: OAuth connection, Google client, manual sync, scheduled sync, and normalized imports.
  3. Automation release: activity log projection, sport mappings, habit rules, webhooks, and analytics polish.

This lets us ship the connection safely before automatic behavior starts changing a user's activity and habit history.


Phase 0 - Repository Preparation

0.1 Update Allowed Gems

Update AGENTS.md before adding the new gem so the repo rules match the implementation.

Add this under the allowed integration gems:

gem "googleauth", "~> 1.16"

Keep the note that omniauth-google-oauth2 is still used for login OAuth and should not be treated as the Google Health API client.

0.2 Add Gems

Update Gemfile near the existing OmniAuth section:

# Google Health API โ€” OAuth2 token refresh only. All Google Health REST calls
# are made directly via Net::HTTP from Health::Google::Client.
gem "googleauth", "~> 1.16"

Then run:

bundle install

0.3 Verify Connectivity

After bundling, hit the discovery doc from a one-off command to confirm network reachability and the v4 API surface:

require "net/http"
require "uri"
Net::HTTP.get(URI("https://health.googleapis.com/$discovery/rest?version=v4"))

Phase 1 - Configuration And Credentials

1.1 Rails Credentials

Store Google Health configuration in encrypted credentials.

Suggested shape:

google_health:
  client_id: "..."
  client_secret: "..."
  project_id: "..."
  webhook_secret: "..."

Use these through a small config object, not scattered credential lookups.

1.2 Config Object

Create:

app/models/health/google/configuration.rb

Responsibilities:

  • Read Rails.application.credentials.google_health.
  • Expose client_id, client_secret, project_id, webhook_secret.
  • Expose redirect URI helper.
  • Expose default read scopes.
  • Raise a clear error if required production credentials are missing.

Suggested default scopes:

SCOPES = [
  "https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly",
  "https://www.googleapis.com/auth/googlehealth.sleep.readonly",
  "https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly",
  "https://www.googleapis.com/auth/googlehealth.nutrition.readonly",
  "https://www.googleapis.com/auth/googlehealth.profile.readonly"
].freeze

Start with readonly scopes. Writing data back to Google Health is not part of v1.

1.3 Token Encryption Check

Before adding token fields, verify whether Active Record encryption is already configured.

If configured, use encrypted attributes on Health::Connection:

encrypts :access_token
encrypts :refresh_token

If not configured, add Rails encryption keys before shipping this feature. Do not store OAuth tokens as plain text.


Phase 2 - Database Schema

Create migrations in this order.

2.1 health_connections

Purpose: stores provider authorization, account identity, and sync state.

create_table :health_connections do |t|
  t.references :user, null: false, foreign_key: true
  t.string :provider, null: false, default: "google_health"
  t.string :status, null: false, default: "pending"
  t.string :google_user_id
  t.text :access_token
  t.text :refresh_token
  t.datetime :access_token_expires_at
  t.json :scopes, default: []
  t.json :settings, default: {}
  t.datetime :last_synced_at
  t.datetime :last_incremental_sync_at
  t.datetime :last_backfill_at
  t.datetime :last_webhook_at
  t.datetime :disconnected_at
  t.text :last_error
  t.timestamps
end

add_index :health_connections, [:user_id, :provider], unique: true
add_index :health_connections, [:provider, :google_user_id]
add_index :health_connections, :status

If encrypted attributes require different column names in this app, adapt before generating the migration.

2.2 health_data_points

Purpose: normalized health samples, intervals, and daily summaries.

create_table :health_data_points do |t|
  t.references :user, null: false, foreign_key: true
  t.references :health_connection, null: false, foreign_key: true
  t.string :provider, null: false, default: "google_health"
  t.string :external_id, null: false
  t.string :data_type, null: false
  t.string :record_kind, null: false
  t.datetime :started_at
  t.datetime :ended_at
  t.date :date, null: false
  t.decimal :value, precision: 15, scale: 4
  t.string :unit
  t.string :source_name
  t.string :source_device
  t.json :metadata, default: {}
  t.json :raw_payload, default: {}
  t.datetime :deleted_at
  t.timestamps
end

add_index :health_data_points, [:provider, :external_id], unique: true
add_index :health_data_points, [:user_id, :data_type, :date]
add_index :health_data_points, [:user_id, :date]
add_index :health_data_points, :deleted_at

2.3 health_exercise_sessions

Purpose: normalized exercise sessions that can create or update activity logs.

create_table :health_exercise_sessions do |t|
  t.references :user, null: false, foreign_key: true
  t.references :health_connection, null: false, foreign_key: true
  t.references :activity_log, foreign_key: true
  t.references :sport, foreign_key: true
  t.string :provider, null: false, default: "google_health"
  t.string :external_id, null: false
  t.string :exercise_type, null: false
  t.string :title
  t.datetime :started_at, null: false
  t.datetime :ended_at, null: false
  t.date :date, null: false
  t.integer :duration_minutes
  t.decimal :distance_meters, precision: 12, scale: 2
  t.decimal :calories_kcal, precision: 12, scale: 2
  t.integer :average_heart_rate
  t.integer :max_heart_rate
  t.string :source_name
  t.string :source_device
  t.json :route_summary, default: {}
  t.json :metrics, default: {}
  t.json :raw_payload, default: {}
  t.datetime :deleted_at
  t.timestamps
end

add_index :health_exercise_sessions, [:provider, :external_id], unique: true
add_index :health_exercise_sessions, [:user_id, :date]
add_index :health_exercise_sessions, [:user_id, :exercise_type]
add_index :health_exercise_sessions, :deleted_at

2.4 health_sport_mappings

Purpose: maps provider exercise types to Lifehub sports.

create_table :health_sport_mappings do |t|
  t.references :user, null: false, foreign_key: true
  t.references :sport, foreign_key: true
  t.string :provider, null: false, default: "google_health"
  t.string :external_exercise_type, null: false
  t.string :activity_type
  t.boolean :auto_create_activity_logs, null: false, default: true
  t.boolean :auto_create_sport, null: false, default: false
  t.json :settings, default: {}
  t.timestamps
end

add_index :health_sport_mappings, [:user_id, :provider, :external_exercise_type], unique: true, name: "idx_health_sport_mappings_unique_provider_type"

Important behavior:

  • sport_id can be null.
  • If mapped to a sport, activity logs use that sport.
  • If no sport is mapped, imported logs may still be created with sport_id: nil when the user allows unmapped imports.
  • If auto_create_sport is true, the importer creates a provider sport once and saves it to the mapping.

2.5 health_habit_rules

Purpose: defines when synced health/activity data completes habits automatically.

create_table :health_habit_rules do |t|
  t.references :user, null: false, foreign_key: true
  t.references :habit, null: false, foreign_key: true
  t.references :sport, foreign_key: true
  t.string :provider, null: false, default: "google_health"
  t.string :rule_type, null: false
  t.string :external_exercise_type
  t.string :data_type
  t.decimal :threshold_value, precision: 15, scale: 4
  t.string :threshold_unit
  t.boolean :enabled, null: false, default: true
  t.json :settings, default: {}
  t.timestamps
end

add_index :health_habit_rules, [:user_id, :habit_id]
add_index :health_habit_rules, [:user_id, :enabled]

Suggested rule_type values:

  • exercise_session - complete when a matching exercise session exists.
  • sport_activity - complete when an imported activity log matches a Lifehub sport.
  • daily_metric_threshold - complete when a daily metric reaches a threshold, such as steps >= 8000.
  • duration_threshold - complete when total activity duration reaches a threshold.

2.6 Extend activity_logs

Purpose: distinguish manual logs from imported logs and make projections idempotent.

add_column :activity_logs, :source, :string, null: false, default: "manual"
add_column :activity_logs, :source_provider, :string
add_column :activity_logs, :source_external_id, :string
add_column :activity_logs, :external_activity_type, :string
add_column :activity_logs, :started_at, :datetime
add_column :activity_logs, :ended_at, :datetime
add_column :activity_logs, :imported_at, :datetime
add_column :activity_logs, :provider_metadata, :json, default: {}

add_index :activity_logs, [:user_id, :source_provider, :source_external_id], unique: true, name: "idx_activity_logs_import_id"
add_index :activity_logs, [:user_id, :source, :date]

Model implication:

  • Change belongs_to :sport to belongs_to :sport, optional: true.
  • Add a validation that manual activity logs still require a sport if the existing UI depends on it.
  • Imported activity logs may have no sport_id so Google Health custom/unmapped activities are supported.

Phase 3 - Models And Associations

3.1 User Associations

Add to User:

has_many :health_connections, dependent: :destroy
has_many :health_data_points, dependent: :destroy
has_many :health_exercise_sessions, dependent: :destroy
has_many :health_sport_mappings, dependent: :destroy
has_many :health_habit_rules, dependent: :destroy

3.2 New Models

Create:

app/models/health/connection.rb
app/models/health/data_point.rb
app/models/health/exercise_session.rb
app/models/health/sport_mapping.rb
app/models/health/habit_rule.rb

Model responsibilities:

  • Health::Connection: token state, provider status, sync settings.
  • Health::DataPoint: metric normalization and scope helpers.
  • Health::ExerciseSession: exercise normalization and projection status.
  • Health::SportMapping: provider exercise type to Lifehub sport behavior.
  • Health::HabitRule: validates automatic habit completion rules.

3.3 Enums

Use string enums where state is fixed.

Suggested enums:

# Health::Connection
enum :provider, { google_health: "google_health" }, default: :google_health
enum :status, {
  pending: "pending",
  connected: "connected",
  sync_failed: "sync_failed",
  disconnected: "disconnected",
  revoked: "revoked"
}, default: :pending

# Health::DataPoint
enum :record_kind, {
  sample: "sample",
  interval: "interval",
  daily: "daily",
  summary: "summary"
}

# Health::HabitRule
enum :rule_type, {
  exercise_session: "exercise_session",
  sport_activity: "sport_activity",
  daily_metric_threshold: "daily_metric_threshold",
  duration_threshold: "duration_threshold"
}

3.4 ActivityLog Source Enum

Add an enum or constant-backed validation:

enum :source, {
  manual: "manual",
  google_health: "google_health"
}, default: :manual

If Rails enum naming conflicts with source, use constants and inclusion validation instead.


Phase 4 - Authorization And Routes

4.1 Policies

Create:

app/policies/health_policy.rb
app/policies/health/connection_policy.rb
app/policies/health/sport_mapping_policy.rb
app/policies/health/habit_rule_policy.rb

Policy pattern:

  • Singleton health dashboard: signed-in user can view.
  • Records: user can manage records where record.user_id == user.id.
  • Scopes: scope.where(user_id: user.id).

4.2 Routes

Add authenticated routes:

resource :health, only: %i[show], controller: "health" do
  post :sync
end

resources :health_connections, only: %i[index destroy] do
  collection do
    get :google_authorize
    get :google_callback
  end
end

resources :health_sport_mappings, only: %i[index update]
resources :health_habit_rules, only: %i[index create update destroy]

namespace :webhooks do
  post "google_health", to: "google_health#create"
end

Keep controllers flat unless namespacing is already established for a specific boundary. Webhooks can live under Webhooks:: because they are externally called endpoints.

4.3 Controllers

Create:

app/controllers/health_controller.rb
app/controllers/health_connections_controller.rb
app/controllers/health_sport_mappings_controller.rb
app/controllers/health_habit_rules_controller.rb
app/controllers/webhooks/google_health_controller.rb

Controller responsibilities:

  • HealthController#show: dashboard with connection state, last sync, summary metrics, recent imported sessions.
  • HealthController#sync: enqueue Health::SyncJob for current user's connection.
  • HealthConnectionsController#google_authorize: redirect to Google consent.
  • HealthConnectionsController#google_callback: exchange code, store tokens, enqueue initial sync.
  • HealthConnectionsController#destroy: revoke or mark disconnected and stop future syncs.
  • HealthSportMappingsController#index/update: let users map provider exercise types.
  • HealthHabitRulesController: CRUD for auto-completion rules.
  • Webhooks::GoogleHealthController#create: validate webhook request, enqueue webhook sync.

Phase 5 - Google OAuth Connection

5.1 OAuth Client

Create:

app/models/health/google/oauth_client.rb

Responsibilities:

  • Build authorization URL with readonly Google Health scopes.
  • Include a signed state value tied to the current user.
  • Request offline access so a refresh token is returned.
  • Exchange authorization code for access and refresh tokens.
  • Fetch Google Health user identity/profile after connection.

Important behavior:

  • Do not reuse login-only OmniAuth callbacks.
  • Do not add Health scopes to normal sign-in.
  • Store granted scopes on Health::Connection#scopes.
  • If Google does not return a new refresh token on reconnect, keep the existing refresh token unless the user intentionally disconnects.

5.2 State Verification

Use Rails message verifier for OAuth state.

State payload:

{
  user_id: current_user.id,
  purpose: "google_health_connection",
  nonce: SecureRandom.hex(16),
  issued_at: Time.current.to_i
}

Reject callback if:

  • state is missing or invalid.
  • state is expired.
  • user_id does not match current_user.id.
  • Google returns an error.

5.3 Token Refresh

Create:

app/models/health/google/token_refresher.rb

Responsibilities:

  • Refresh expired access tokens with googleauth/Signet.
  • Persist new access token and expiration.
  • Mark connection revoked if refresh fails because consent was revoked.
  • Mark connection sync_failed for transient failures.

Phase 6 - Google Health API Client

6.1 Client Wrapper

Create:

app/models/health/google/client.rb

Responsibilities:

  • Instantiate the generated Health API service.
  • Attach authorization from Health::Connection.
  • Expose Lifehub-friendly methods:
    • identity
    • profile
    • settings
    • data_points(data_type:, start_time:, end_time:, page_token: nil)
    • daily_rollup(data_type:, start_date:, end_date:)
    • exercise_sessions(start_time:, end_time:)
    • subscribe_webhook!
  • Refresh and retry once after 401.
  • Let jobs handle retry/backoff for rate limits and transient failures.

6.2 Importers

Create:

app/models/health/google/data_point_importer.rb
app/models/health/google/exercise_importer.rb
app/models/health/google/daily_summary_importer.rb

Responsibilities:

  • Fetch provider data through Health::Google::Client.
  • Normalize payloads into model attributes.
  • Upsert by provider + external_id.
  • Soft-delete records when Google reports deletions or reconcile indicates records no longer exist.
  • Store raw_payload for auditing.

6.3 Data Type Registry

Create:

app/models/health/google/data_type_registry.rb

Responsibilities:

  • Centralize supported Google Health data types.
  • Define Lifehub unit normalization.
  • Define whether a data type imports as raw data points, daily summaries, exercise sessions, or all of those.

Start with:

  • exercise
  • steps
  • distance
  • active-minutes
  • active-zone-minutes
  • total-calories
  • sleep
  • heart-rate
  • daily-resting-heart-rate
  • heart-rate-variability
  • daily-heart-rate-variability
  • oxygen-saturation
  • vo2-max
  • weight
  • body-fat
  • height
  • hydration-log

Phase 7 - Sync Jobs

7.1 Jobs

Create:

app/jobs/health/sync_job.rb
app/jobs/health/backfill_job.rb
app/jobs/health/webhook_sync_job.rb
app/jobs/health/daily_summary_job.rb
app/jobs/health/project_activity_logs_job.rb
app/jobs/health/evaluate_habit_rules_job.rb

Job responsibilities:

  • Health::SyncJob: incremental sync for one connection.
  • Health::BackfillJob: historical import in bounded chunks.
  • Health::WebhookSyncJob: short incremental sync after Google webhook.
  • Health::DailySummaryJob: roll up daily metrics after raw imports.
  • Health::ProjectActivityLogsJob: create/update ActivityLog records from exercise sessions.
  • Health::EvaluateHabitRulesJob: complete habits based on synced sessions and daily metrics.

7.2 Sync Runner

Create:

app/models/health/sync_runner.rb

Responsibilities:

  • Coordinate importers for one connection.
  • Compute sync window.
  • Persist last_synced_at only after successful import.
  • Enqueue projection and habit evaluation after imported records are committed.
  • Store errors on Health::Connection#last_error.

Window rules:

  • First manual sync: last 30 days.
  • Initial backfill: chunk backward by month until configured limit.
  • Scheduled sync: from last_incremental_sync_at - 1.hour to now, to tolerate late provider updates.
  • Webhook sync: from last_webhook_at - 1.hour to now.

7.3 Recurring Jobs

Update config/recurring.yml:

health_sync:
  class: Health::SyncJob
  schedule: every 2 hours

If Solid Queue recurring jobs require arguments, create a fan-out job:

app/jobs/health/sync_connected_users_job.rb

That job finds connected Google Health connections and enqueues Health::SyncJob per connection.


Phase 8 - Activity Log Projection

8.1 Projector

Create:

app/models/health/activity_log_projector.rb

Responsibilities:

  • Convert Health::ExerciseSession into an ActivityLog.
  • Upsert by user_id + source_provider + source_external_id.
  • Link health_exercise_session.activity_log_id.
  • Update existing imported activity logs when provider data changes.
  • Soft-delete or archive activity logs if the source session is deleted, based on product decision.

8.2 Projection Rules

For each exercise session:

  1. Find or create Health::SportMapping by external_exercise_type.
  2. If mapping has sport_id, use that sport.
  3. If mapping has auto_create_sport, create a Sport named from the provider exercise type and save it on the mapping.
  4. If there is no sport and unmapped imports are enabled, create an activity log with sport_id: nil and activity_type: "custom".
  5. If unmapped imports are disabled, keep only the Health::ExerciseSession and mark it as not projected.

8.3 ActivityLog Attributes

Suggested projection:

{
  user: session.user,
  sport: mapped_sport,
  source: "google_health",
  source_provider: session.provider,
  source_external_id: session.external_id,
  external_activity_type: session.exercise_type,
  activity_type: mapped_sport&.sport_type || "custom",
  date: session.date,
  started_at: session.started_at,
  ended_at: session.ended_at,
  duration_minutes: session.duration_minutes,
  notes: generated_notes,
  sport_data: session.metrics,
  provider_metadata: {
    source_name: session.source_name,
    source_device: session.source_device,
    distance_meters: session.distance_meters,
    calories_kcal: session.calories_kcal,
    average_heart_rate: session.average_heart_rate,
    max_heart_rate: session.max_heart_rate
  },
  imported_at: Time.current
}

8.4 Manual Edits To Imported Logs

Add a product rule before implementation:

  • Imported logs can be edited for Lifehub-only fields such as notes, sport mapping, and intensity.
  • Provider-owned fields such as start/end time, duration, and metrics should be updated by sync.
  • If a user changes the sport on an imported log, save or suggest a Health::SportMapping update.

Phase 9 - Habit Automation

9.1 Evaluator

Create:

app/models/health/habit_rule_evaluator.rb

Responsibilities:

  • Evaluate enabled Health::HabitRule records after sync.
  • Complete habits when evidence satisfies the rule.
  • Use Habit#complete_on! so streaks and goal callbacks continue to work.
  • Keep completion idempotent through the existing unique index on habit_completions.
  • Add notes that identify the completion source.

9.2 Rule Examples

Running habit completed by imported runs:

rule_type: exercise_session
external_exercise_type: running
threshold_value: 1

Gym habit completed by a mapped sport:

rule_type: sport_activity
sport_id: <gym sport id>
threshold_value: 1

Steps habit completed by daily metric:

rule_type: daily_metric_threshold
data_type: steps
threshold_value: 8000
threshold_unit: count

Cardio habit completed by duration:

rule_type: duration_threshold
external_exercise_type: cycling
threshold_value: 30
threshold_unit: minutes

9.3 XP Decision

For v1, do not award XP for automatic imports.

Reason: existing XP methods are called from manual controllers and are not idempotent for provider replay. Automatic XP can be added later with a unique source key such as:

google_health:<habit_id>:<date>:<rule_id>
google_health_activity:<activity_log_id>

Document this clearly in the Health UI so users understand that imports help tracking and goals, while manual actions remain the intentional gamification path in v1.


Phase 10 - Webhooks

10.1 Google Subscriber Setup

Create a class:

app/models/health/google/subscriber.rb

Responsibilities:

  • Create or update Google Health subscriber configuration.
  • Store subscriber identifiers in Health::Connection#settings.
  • Disable subscriber on disconnect if Google supports it for the configured resource.

10.2 Webhook Controller

Webhooks::GoogleHealthController#create should:

  • Skip Devise authentication if Google cannot send user session cookies.
  • Validate request authenticity using the Google Health webhook verification mechanism and webhook_secret.
  • Parse only the minimal event metadata.
  • Find the matching Health::Connection.
  • Update last_webhook_at.
  • Enqueue Health::WebhookSyncJob.
  • Return a fast 200 OK.

If the exact Google Health webhook signing scheme differs from this expectation, implement the documented verification flow from the API reference before enabling production webhooks.


Phase 11 - Views And UX

11.1 Navigation

Add Health to the authenticated app navigation near activity, habits, or analytics.

11.2 Health Dashboard

Create:

app/views/health/show.html.erb

Show:

  • Connection status.
  • Connect/disconnect actions.
  • Last sync time.
  • Manual sync button.
  • Recent imported exercise sessions.
  • Recent activity logs created by Google Health.
  • Basic all-time and recent metrics: steps, distance, active minutes, sleep, resting heart rate, weight.
  • Sync errors with a retry action.

11.3 Sport Mapping UI

Create:

app/views/health_sport_mappings/index.html.erb

Show provider exercise types discovered from imports and allow:

  • Map to existing sport.
  • Leave unmapped but still create custom activity logs.
  • Auto-create a Lifehub sport.
  • Disable automatic activity log creation for that exercise type.

11.4 Habit Rule UI

Create:

app/views/health_habit_rules/index.html.erb

Allow the user to create rules from existing habits:

  • Habit selector.
  • Rule type selector.
  • Sport or provider exercise type selector.
  • Data type selector for metric-based rules.
  • Threshold value and unit.
  • Enabled toggle.

Phase 12 - Analytics

12.1 Health Summary Calculator

Create:

app/models/health/summary_calculator.rb

Responsibilities:

  • Summarize health data by period.
  • Provide dashboard cards and charts.
  • Keep queries scoped to user.health_data_points and user.health_exercise_sessions.

Initial metrics:

  • Total workouts.
  • Total exercise minutes.
  • Total distance.
  • Steps by day/week/month.
  • Sleep duration by day/week/month.
  • Resting heart rate trend.
  • Weight trend.

12.2 Activity And Goal Integration

No new goal tracking mode is required for v1 if activity logs and habit completions are created correctly.

Existing callbacks should refresh:

  • activity_log_count
  • activity_log_duration
  • habit_streak
  • habit_completions

Add direct Health goal modes later only if users need goals based on metrics that do not project to activity logs or habits.


Phase 13 - Tests

Use Minitest and fixtures.

13.1 Model Tests

Add tests for:

  • Health::Connection validations and enums.
  • Health::DataPoint idempotency fields.
  • Health::ExerciseSession validates required provider fields.
  • Health::SportMapping supports null sport mappings.
  • Health::HabitRule validates rule-specific fields.
  • ActivityLog accepts nil sport for imported logs but preserves manual behavior.

13.2 Importer Tests

Add tests for:

  • Exercise import creates Health::ExerciseSession once.
  • Re-import updates the existing session.
  • Data point import upserts by provider/external ID.
  • Token refresh is called on expired token.
  • Revoked token marks connection revoked.

13.3 Projector Tests

Add tests for:

  • Mapped provider exercise creates activity log with sport.
  • Unmapped provider exercise creates activity log without sport when enabled.
  • Auto-create sport creates one sport and reuses it.
  • Reprojection updates the same activity log.
  • Deleted provider session does not duplicate or orphan logs.

13.4 Habit Rule Tests

Add tests for:

  • Exercise session completes habit on the session date.
  • Daily steps threshold completes habit once.
  • Duration threshold sums same-day sessions correctly.
  • Disabled rules do nothing.
  • Re-running evaluator does not create duplicate completions.

13.5 Controller Tests

Add tests for:

  • Health dashboard requires authentication.
  • Manual sync enqueues job.
  • OAuth callback rejects invalid state.
  • OAuth callback creates or updates connection.
  • Mapping updates are user-scoped.
  • Habit rules are user-scoped.
  • Webhook validates signature and enqueues sync.

13.6 Job Tests

Add tests for:

  • Sync job runs importer and updates connection timestamps.
  • Sync job records errors and does not advance sync timestamp on failure.
  • Backfill job chunks time windows.
  • Webhook sync job uses a short incremental window.

Phase 14 - External Google Setup

The external setup checklist lives in docs/google-health/project-plan.md. During implementation, complete it in this order:

  1. Create or select a Google Cloud project.
  2. Enable Google Health API.
  3. Configure OAuth consent screen.
  4. Add readonly Google Health scopes.
  5. Create OAuth 2.0 Web Application credentials.
  6. Add redirect URI:
https://your-domain.com/health_connections/google_callback

For local development, add:

http://localhost:3000/health_connections/google_callback

Use the actual local port when testing.

  1. Add production webhook URL:
https://your-domain.com/webhooks/google_health
  1. Store credentials in Rails encrypted credentials.
  2. Complete Google verification steps if the selected scopes require app verification.
  3. Test with a Google account that has Health data available.

Phase 15 - Rollout And Safety

15.1 Feature Flag

Add a simple configuration guard before exposing the UI broadly:

Rails.configuration.x.google_health.enabled

Default to false in production until credentials, OAuth consent, and webhook setup are complete.

15.2 Initial Rollout

Recommended order:

  1. Enable connect/disconnect only.
  2. Enable manual sync for one test user.
  3. Enable normalized imports without creating activity logs.
  4. Enable activity log projection.
  5. Enable habit rules.
  6. Enable recurring sync.
  7. Enable webhooks.

15.3 Observability

Track at minimum:

  • Number of connected users.
  • Last successful sync per connection.
  • Sync failures by error class.
  • Imported exercise sessions count.
  • Activity logs created by Google Health.
  • Habit completions created by rules.
  • Webhook deliveries received.

Keep errors visible in the Health dashboard and logs.


Implementation Checklist

  • Update AGENTS.md allowed gems.
  • Add google-apis-health_v4 and googleauth to Gemfile.
  • Bundle and verify generated Health API service class.
  • Add Google Health encrypted credentials.
  • Add Health configuration model.
  • Add database migrations.
  • Add Health models and user associations.
  • Update ActivityLog to support imported source fields and optional sport for imports.
  • Add policies.
  • Add routes and controllers.
  • Add OAuth authorization and callback flow.
  • Add token refresher.
  • Add Google Health API client wrapper.
  • Add data type registry.
  • Add data point and exercise importers.
  • Add sync jobs and recurring fan-out job.
  • Add activity log projector.
  • Add sport mapping UI.
  • Add habit rule evaluator.
  • Add habit rule UI.
  • Add Health dashboard and navigation.
  • Add webhook subscriber and webhook controller.
  • Add tests for models, importers, projectors, rules, controllers, and jobs.
  • Run bin/rails test or targeted tests during implementation.
  • Run bin/ci before pushing.

Key Open Decisions Before Coding

  1. How far should initial backfill go? Recommended v1 default: 12 months, with monthly chunks.
  2. Should imported activity logs be editable? Recommended: yes for Lifehub-owned fields, no for provider-owned metrics.
  3. Should automatic imports award XP? Recommended v1: no. Add idempotent XP later.
  4. Should deleted provider sessions delete activity logs? Recommended: soft-delete or mark source deleted first, then decide based on user-facing behavior.
  5. Should unmapped provider exercises create activity logs by default? Recommended: yes, with sport_id: nil and activity_type: custom, because the user explicitly wants custom/unregistered provider activities supported.

Definition Of Done

The Google Health implementation is complete when:

  • A user can connect and disconnect Google Health from Lifehub.
  • Tokens are encrypted and refresh correctly.
  • Manual sync imports normalized health data without duplicates.
  • Recurring sync keeps connected users updated.
  • Google Health exercise sessions create or update Lifehub activity logs idempotently.
  • Imported activities can be mapped to existing sports, auto-created sports, or no sport.
  • Habit rules can auto-complete habits from imported sessions or daily metrics.
  • Existing activity and habit goals refresh from imported records.
  • Webhooks enqueue incremental syncs safely.
  • Tests cover the sync, projection, and rule automation paths.
  • External Google setup is documented and completed for the target environment.