Skip to content

feat: sync meal logs to Google Health - #176

Merged
QueryPlanner merged 2 commits into
mainfrom
feat/google-health-nutrition-sync
Aug 26, 2026
Merged

feat: sync meal logs to Google Health#176
QueryPlanner merged 2 commits into
mainfrom
feat/google-health-nutrition-sync

Conversation

@QueryPlanner

Copy link
Copy Markdown
Owner

What

Exports newly logged, edited, and deleted meals from private Telegram chats to Google Health's nutrition API, alongside the existing read-only summary connector.

Why

Users connected to Google Health had no way to get Blacki-logged meals into Google Health itself. This adds that as an opt-in capability gated on two new OAuth scopes, without weakening any existing privacy or local-save guarantees.

How

  • MealService.mutate() saves a meal locally and enqueues its export intent in the same SQLite transaction, so a local save never depends on Google's availability.
  • NutritionExportWorker is a background loop that dispatches due export jobs once a minute, or immediately when wake() is called after a local save (near-instant sync without extra rate-limit exposure).
  • An immutable per-meal revision ledger tracks every create/delete attempt against Google, since anonymous nutrition-log points can't be edited — only deleted and recreated — and ambiguous outcomes (timeouts, 5xx) are reconciled via get_data_point before ever retrying, to avoid duplicate records.
  • OAuth access tokens are cached in memory per account (with an expiry safety margin) instead of refreshed on every dispatched row.
  • Exponential backoff honors Retry-After, capped at 1 hour.
  • /disconnect_health and account-switch flows already cancelled/paused nutrition export correctly from prior work; this PR only adds the dispatch engine and enrollment logic on top.
  • Docs, .env.example, and the LLM's nutrition/health-policy prompts are updated for the new scopes and sync-status semantics.

Bugs found and fixed along the way

  • The meal-edit SQL bound 9 parameters to a 10-placeholder UPDATE (missing fat_g), so every edit for a connected private-chat user crashed.
  • The worker's stale-revision check didn't account for delete revisions, whose resource_name always targets the old point rather than the meal's current desired state — it would misidentify a still-needed delete as superseded and skip it, leaving the old point on Google forever once the replacement create landed.

Tests

  • pytest --cov=src — 1795 passed, 1 skipped, 100.00% coverage (repo's required floor)
  • ruff check . / ruff format --check . — clean
  • mypy . — clean
  • Manually verified end-to-end against a real Telegram bot + Google OAuth app: logged a meal in a private chat, confirmed google_health_sync: pending, and confirmed it landed in the Google Health app on a phone within seconds of the save (wake-early path)

- Add durable local-first export queue and revision ledger
- Add background worker with retry, backoff, and ordering
- Cache OAuth access tokens per account to cut token calls
- Add wake-early dispatch so sync starts right after a save
- Fix meal edit crash from a missing fat_g bind parameter
- Fix worker dropping a queued delete during point recreation
- Wire export worker lifecycle and wake signal into container
- Update docs and prompts for the new nutrition scopes
)
return

connection = await self.storage.get_connection(telegram_user_id)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

[P1] Preserve read-only Health connections

This guard never checks connection.scopes before dispatching an optional nutrition write. If a user reconnects with only read scopes while an older revision is due, Google returns 403 from the nutrition create or batchDelete endpoint. The client classifies that as GoogleHealthAuthError, and the catch below calls mark_reauthorization_required, clearing the refresh token and disabling read-only Health summaries. Preserve the connection and mark only nutrition exports as authorization-required when nutrition scopes are absent.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 5f7602a: the guard now checks connection.scopes against GOOGLE_HEALTH_NUTRITION_SCOPES before dispatching. When the scope is missing, only the export is paused (nutrition.result(..., "authorization_required")); the connection itself, its refresh token, and read-only Health summaries are untouched. Added test_worker_preserves_connection_when_nutrition_scope_missing.

Comment thread src/blacki/health/nutrition_worker.py Outdated
) -> tuple[str, Exception | None]:
sequence = int(revision["sequence"])
resource_name = str(revision["resource_name"])
if str(revision["state"]) == "uncertain":

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

[P1] Reconcile persisted in-flight writes

_select_pending_revision explicitly says in_flight and uncertain revisions must be reconciled first, but this branch handles only uncertain. A crash after line 237 persists in_flight, and the next tick sends the same named create again without the exact get check. Treat persisted in_flight rows as uncertain before POST and cover the restart case.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 5f7602a: both _dispatch_upsert and _dispatch_delete now treat a persisted in_flight state the same as uncertain (GET-verify before ever re-POSTing), covering the crash-then-restart case. Added test_worker_reconciles_persisted_in_flight_upsert and test_worker_reconciles_persisted_in_flight_delete.

return _append_google_health_sync_counts(text, result)


def _append_google_health_sync_counts(text: str, result: object) -> str:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

[P2] Include sync counts in refresh output

This helper is not wired to the successful production path: SyncResult has no google_health_sync field, and _refresh_health drops result on success and sends only format_health_summary(summary). Therefore /health_refresh never shows the new pending, synced, failed, and authorization-required counts; the test passes only because it supplies a synthetic object. Add the counts to SyncResult and append them on the success path.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 5f7602a: SyncResult now carries google_health_sync (populated from storage.nutrition.counts() via a new _sync_result helper used at every return site in refresh_user/sync_user), and _refresh_health's success branch appends those counts to the summary text instead of dropping result. Added test_health_refresh_success_includes_sync_counts and a counts assertion in the existing service-level sync test.

- Preserve read-only Health connection when nutrition scopes
  are absent, pausing only the export instead of disabling reads
- Reconcile persisted in_flight upsert/delete revisions the same
  way as uncertain ones, fixing duplicate dispatch after a crash
- Add google_health_sync counts to SyncResult and surface them
  on the successful /health_refresh path, not just failure paths
@QueryPlanner
QueryPlanner merged commit 6c6bbad into main Aug 26, 2026
0 of 2 checks passed
@QueryPlanner
QueryPlanner deleted the feat/google-health-nutrition-sync branch August 26, 2026 15:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant