mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
feat(google-health): add the settings card, runbook, and translations
Surface the Google Health integration to the operator and document the setup honestly. - A settings card matching the other integration cards: the BYO Google client id and secret form, connect and disconnect, a manual sync, and a distinct reconnect prompt when the connection needs fresh consent. - Register the card in the connections panel and extend the integration key, the docs-link provider, and the query-key factory. - Translations for the card copy across all six locales. The copy states plainly that this reads Fitbit, Pixel Watch, and Fitbit Air through the user's Google account, that it succeeds the Fitbit connection Google retires in September 2026, that the operator registers their own Google client, that stress and readiness are not available, and that a periodic reconnect may be needed. - Rewrite the Google Health runbook into the real setup: a Google Cloud project, the Health API, an OAuth consent screen kept in Testing, a Web Server client, the callback redirect, the four Restricted scopes, the hundred-test-user ceiling, and the reconnect caveat. - Regenerate the OpenAPI contract for the new measurement source.
This commit is contained in:
1 parent
b26a18d35e
commit
9f7947a729
16 files changed
+1035
-50
No files matched your search
@@ -2171,6 +2171,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- in: query
|
||||
name: valueMin
|
||||
schema:
|
||||
@@ -3784,6 +3785,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
activeEnergy:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -3802,6 +3804,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
walkingRunningDistance:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -3820,6 +3823,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
flightsClimbed:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -3838,6 +3842,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
sleep:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -3856,6 +3861,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
weight:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -3874,6 +3880,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
bloodPressure:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -3892,6 +3899,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
pulse:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -3910,6 +3918,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
bodyFat:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -3928,6 +3937,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
bodyTemperature:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -3946,6 +3956,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
spo2:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -3964,6 +3975,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
hrv:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -3982,6 +3994,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
restingHeartRate:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4000,6 +4013,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
vo2Max:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4018,6 +4032,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
skinTemperature:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4036,6 +4051,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
respiratoryRate:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4054,6 +4070,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
recovery:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4072,6 +4089,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
stress:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4090,6 +4108,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
sleepDebt:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4108,6 +4127,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
metricPriority:
|
||||
type: object
|
||||
properties:
|
||||
@@ -4129,6 +4149,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
activeEnergy:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4147,6 +4168,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
walkingRunningDistance:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4165,6 +4187,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
flightsClimbed:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4183,6 +4206,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
sleep:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4201,6 +4225,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
weight:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4219,6 +4244,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
bloodPressure:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4237,6 +4263,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
pulse:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4255,6 +4282,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
bodyFat:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4273,6 +4301,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
bodyTemperature:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4291,6 +4320,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
spo2:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4309,6 +4339,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
hrv:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4327,6 +4358,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
restingHeartRate:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4345,6 +4377,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
vo2Max:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4363,6 +4396,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
skinTemperature:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4381,6 +4415,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
respiratoryRate:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4399,6 +4434,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
recovery:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4417,6 +4453,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
stress:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4435,6 +4472,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
sleepDebt:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4453,6 +4491,7 @@ paths:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
deviceTypePriority:
|
||||
type: object
|
||||
properties:
|
||||
@@ -13876,6 +13915,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
notes:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -14624,6 +14664,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
notes:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -14868,6 +14909,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- type: "null"
|
||||
start:
|
||||
type: string
|
||||
@@ -14996,6 +15038,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- type: "null"
|
||||
start:
|
||||
type: string
|
||||
@@ -15175,6 +15218,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
description: v1.25.0 — the active source the debt is resolved FROM, picked off the user's `sleepDebt` source ladder.
|
||||
`COMPUTED` is HealthLog's own rolling-balance estimate (the only producer today); a provider value would
|
||||
be a device-native debt. Clients explain the figure when it is `COMPUTED`.
|
||||
@@ -15389,6 +15433,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
externalId:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -15499,6 +15544,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
externalId:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -17767,6 +17813,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
activeEnergy:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -17785,6 +17832,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
walkingRunningDistance:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -17803,6 +17851,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
flightsClimbed:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -17821,6 +17870,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
sleep:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -17839,6 +17889,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
weight:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -17857,6 +17908,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
bloodPressure:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -17875,6 +17927,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
pulse:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -17893,6 +17946,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
bodyFat:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -17911,6 +17965,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
bodyTemperature:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -17929,6 +17984,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
spo2:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -17947,6 +18003,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
hrv:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -17965,6 +18022,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
restingHeartRate:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -17983,6 +18041,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
vo2Max:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18001,6 +18060,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
skinTemperature:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18019,6 +18079,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
respiratoryRate:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18037,6 +18098,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
recovery:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18055,6 +18117,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
stress:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18073,6 +18136,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
sleepDebt:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18091,6 +18155,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
metricPriority:
|
||||
type: object
|
||||
properties:
|
||||
@@ -18112,6 +18177,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
activeEnergy:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18130,6 +18196,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
walkingRunningDistance:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18148,6 +18215,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
flightsClimbed:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18166,6 +18234,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
sleep:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18184,6 +18253,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
weight:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18202,6 +18272,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
bloodPressure:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18220,6 +18291,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
pulse:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18238,6 +18310,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
bodyFat:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18256,6 +18329,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
bodyTemperature:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18274,6 +18348,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
spo2:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18292,6 +18367,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
hrv:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18310,6 +18386,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
restingHeartRate:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18328,6 +18405,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
vo2Max:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18346,6 +18424,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
skinTemperature:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18364,6 +18443,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
respiratoryRate:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18382,6 +18462,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
recovery:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18400,6 +18481,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
stress:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18418,6 +18500,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
sleepDebt:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18436,6 +18519,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
additionalProperties: false
|
||||
deviceTypePriority:
|
||||
type: object
|
||||
@@ -18503,6 +18587,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
activeEnergy:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18521,6 +18606,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
walkingRunningDistance:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18539,6 +18625,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
flightsClimbed:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18557,6 +18644,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
sleep:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18575,6 +18663,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
weight:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18593,6 +18682,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
bloodPressure:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18611,6 +18701,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
pulse:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18629,6 +18720,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
bodyFat:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18647,6 +18739,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
bodyTemperature:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18665,6 +18758,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
spo2:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18683,6 +18777,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
hrv:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18701,6 +18796,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
restingHeartRate:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18719,6 +18815,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
vo2Max:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18737,6 +18834,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
skinTemperature:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18755,6 +18853,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
respiratoryRate:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18773,6 +18872,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
recovery:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18791,6 +18891,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
stress:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18809,6 +18910,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
sleepDebt:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18827,6 +18929,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
metricPriority:
|
||||
type: object
|
||||
properties:
|
||||
@@ -18848,6 +18951,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
activeEnergy:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18866,6 +18970,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
walkingRunningDistance:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18884,6 +18989,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
flightsClimbed:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18902,6 +19008,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
sleep:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18920,6 +19027,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
weight:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18938,6 +19046,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
bloodPressure:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18956,6 +19065,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
pulse:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18974,6 +19084,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
bodyFat:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18992,6 +19103,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
bodyTemperature:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19010,6 +19122,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
spo2:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19028,6 +19141,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
hrv:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19046,6 +19160,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
restingHeartRate:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19064,6 +19179,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
vo2Max:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19082,6 +19198,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
skinTemperature:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19100,6 +19217,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
respiratoryRate:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19118,6 +19236,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
recovery:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19136,6 +19255,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
stress:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19154,6 +19274,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
sleepDebt:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19172,6 +19293,7 @@ components:
|
||||
- OURA
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
additionalProperties: false
|
||||
deviceTypePriority:
|
||||
type: object
|
||||
|
||||
@@ -1,29 +1,79 @@
|
||||
# Google Health integration (Fitbit & Pixel)
|
||||
# Google Health integration (Fitbit + Pixel Watch + Fitbit Air)
|
||||
|
||||
HealthLog reads Fitbit, Pixel Watch and Fitbit Air data through the **Google
|
||||
Health API** (`health.googleapis.com/v4`), the successor to the classic Fitbit
|
||||
Web API. Google retires the classic Fitbit Web API in **September 2026**, and
|
||||
Fitbit access/refresh tokens do not carry over — every account re-consents
|
||||
through Google OAuth. This integration is that path.
|
||||
|
||||
The OAuth app is a **Google Cloud** OAuth client, not a Fitbit developer-console
|
||||
app. Credentials are brought **per user**: each user registers their own Google
|
||||
Cloud OAuth client and pastes the client id/secret into Settings. The internal
|
||||
integration key is `google-health`; it runs alongside the classic `fitbit`
|
||||
integration without touching it.
|
||||
|
||||
> **Beta.** Field mapping is still being verified against live Google Health
|
||||
> accounts, so some values may be missing or change in a future update.
|
||||
> accounts, so some values may be missing or shift in a later update.
|
||||
|
||||
HealthLog reads Fitbit and Pixel Watch data through the **Google Health API**.
|
||||
The OAuth app is therefore a **Google Cloud** OAuth client — not a Fitbit
|
||||
developer-console app. Like WHOOP, credentials are brought per user: each user
|
||||
registers their own Google Cloud OAuth client and pastes the client id/secret
|
||||
into Settings. The internal integration key is `fitbit`.
|
||||
## What you get — and what you don't
|
||||
|
||||
## 1. Create a Google Cloud OAuth client
|
||||
Weight, body fat, heart rate, resting heart rate, heart-rate variability, blood
|
||||
oxygen, respiratory rate, sleep stages, steps, distance, active energy, floors,
|
||||
VO₂ max and workouts flow in on a periodic pull.
|
||||
|
||||
1. In <https://console.cloud.google.com> create (or pick) a project and, under
|
||||
**APIs & Services**, enable the **Google Health API**.
|
||||
2. Configure the OAuth consent screen for the project.
|
||||
3. Under **APIs & Services → Credentials**, create an **OAuth 2.0 Client ID** of
|
||||
type **Web application**.
|
||||
4. **Authorized redirect URI:**
|
||||
`https://your-instance.example.com/api/fitbit/callback`
|
||||
5. Save. Google issues a **Client ID** and **Client Secret** — keep the secret
|
||||
Two honest limits, set by the API itself:
|
||||
|
||||
- **Stress and readiness are not available.** The Google Health API exposes no
|
||||
stress or daily-readiness data type. Nothing HealthLog can do surfaces them.
|
||||
- **Skin temperature is a nightly deviation**, not a raw stream — a single
|
||||
per-night value rather than a continuous trace.
|
||||
|
||||
There is **no webhook** at launch: Google Health syncs on the safety-net cron
|
||||
pull, the same model as Oura and Polar.
|
||||
|
||||
## 1. Create a Google Cloud project and enable the Health API
|
||||
|
||||
1. In the **Google Cloud console** (<https://console.cloud.google.com>) create a
|
||||
project (or pick an existing one).
|
||||
2. Under **APIs & Services → Library**, find and **enable the Google Health
|
||||
API** for that project.
|
||||
|
||||
## 2. Configure the OAuth consent screen — keep it in "Testing"
|
||||
|
||||
1. Under **APIs & Services → OAuth consent screen**, configure the screen for
|
||||
the project.
|
||||
2. Leave the publishing status on **Testing**. This is the deliberate choice for
|
||||
a self-hoster: a Testing-mode client avoids Google verification and the
|
||||
annual third-party CASA security assessment entirely.
|
||||
3. On the **Audience** page, add every account that will connect as a **test
|
||||
user** (**+ Add users**). An unverified client is capped at **100 users** —
|
||||
staying at or below that ceiling keeps you verification-free.
|
||||
|
||||
The cost of staying in Testing is the **7-day refresh-token expiry** — see the
|
||||
caveat at the end. Going past 100 users, or publishing the client, requires
|
||||
Google Trust & Safety verification **plus an annual CASA assessment**, because
|
||||
every Google Health scope is Restricted.
|
||||
|
||||
## 3. Create a "Web Server" OAuth client
|
||||
|
||||
1. Under **APIs & Services → Credentials**, create an **OAuth 2.0 Client ID** of
|
||||
type **Web application** (a "Web Server" client).
|
||||
2. **Authorized redirect URI:**
|
||||
`https://your-instance.example.com/api/google-health/callback`
|
||||
Use your instance's real origin; the path is always
|
||||
`/api/google-health/callback`.
|
||||
3. Save. Google issues a **Client ID** and **Client Secret** — keep the secret
|
||||
out of any chat log.
|
||||
|
||||
## 2. Scopes
|
||||
HealthLog drives the standard Google OAuth 2.0 web-server flow: it sends the
|
||||
browser to the authorization endpoint
|
||||
`https://accounts.google.com/o/oauth2/v2/auth` (with `access_type=offline`,
|
||||
`prompt=consent` and an S256 PKCE challenge), then exchanges the returned code at
|
||||
the token endpoint `https://oauth2.googleapis.com/token`.
|
||||
|
||||
HealthLog requests exactly these read-only scopes:
|
||||
## 4. Scopes
|
||||
|
||||
HealthLog requests exactly these four read-only **Restricted** scopes:
|
||||
|
||||
```
|
||||
https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly
|
||||
@@ -32,48 +82,58 @@ https://www.googleapis.com/auth/googlehealth.sleep.readonly
|
||||
https://www.googleapis.com/auth/googlehealth.profile.readonly
|
||||
```
|
||||
|
||||
Location and intraday scopes are deliberately omitted to keep the
|
||||
Restricted-scope review surface minimal. The authorize request sends
|
||||
`access_type=offline` + `prompt=consent` — Google's requirement to issue a
|
||||
refresh token (the equivalent of WHOOP's `offline` scope).
|
||||
No write scopes are requested — the connection is read-only. The optional ECG
|
||||
and irregular-rhythm (Pixel Watch clinical) scopes stay off unless an operator
|
||||
opts in via `GOOGLE_HEALTH_EXPERIMENTAL_SCOPES=true`; they widen the Restricted
|
||||
review surface and only apply to Pixel Watch sources.
|
||||
|
||||
## 3. Wire the credentials in Settings → Integrations
|
||||
## 5. Wire the credentials in Settings → Integrations
|
||||
|
||||
Credentials are stored per user, not in `.env`. Sign in as the target user, open
|
||||
**Settings → Integrations → Google Health**, and paste the client ID and secret.
|
||||
They are encrypted AES-256-GCM at rest.
|
||||
**Settings → Integrations → Google Health**, and paste the Client ID and Client
|
||||
Secret. They are encrypted AES-256-GCM at rest.
|
||||
|
||||
## 4. Connect
|
||||
## 6. Connect
|
||||
|
||||
Click **Connect with Google Health**. HealthLog redirects to Google's OAuth
|
||||
consent screen, exchanges the returned code for an access + refresh token, stores
|
||||
them encrypted, and runs an initial sync. There is **no webhook** — Google Health
|
||||
syncs on the safety-net cron pull.
|
||||
Click **Connect with Google**. HealthLog redirects to Google's consent screen,
|
||||
exchanges the returned code for an access + refresh token, stores them encrypted,
|
||||
and runs an initial backfill in the background.
|
||||
|
||||
## Redirect URI is validated strictly
|
||||
|
||||
The resolved `redirect_uri` is asserted at connect time to be absolute, `https`
|
||||
(or `http` on localhost), to target exactly `/api/fitbit/callback`, and — when
|
||||
`FITBIT_REDIRECT_URI` is set alongside `NEXT_PUBLIC_APP_URL` — to share its
|
||||
origin. A misconfigured value fails closed rather than redirecting elsewhere.
|
||||
(or `http` on localhost), to target exactly `/api/google-health/callback`, and —
|
||||
when `GOOGLE_HEALTH_REDIRECT_URI` is set alongside `NEXT_PUBLIC_APP_URL` — to
|
||||
share its origin. A misconfigured value fails closed rather than redirecting
|
||||
elsewhere.
|
||||
|
||||
## Optional instance env var
|
||||
|
||||
```env
|
||||
# Only when the callback origin differs from NEXT_PUBLIC_APP_URL:
|
||||
FITBIT_REDIRECT_URI="https://your-instance.example.com/api/fitbit/callback"
|
||||
GOOGLE_HEALTH_REDIRECT_URI="https://your-instance.example.com/api/google-health/callback"
|
||||
```
|
||||
|
||||
The Google Cloud client id/secret are per-user (Settings), not env vars.
|
||||
|
||||
## The 7-day re-consent caveat
|
||||
|
||||
While the OAuth consent screen stays in **Testing** publishing mode, Google
|
||||
**expires the refresh token after 7 days**. When that happens the sync stops with
|
||||
a soft "needs reconnect" state rather than a hard error, and the Google Health
|
||||
card shows a **Reconnect** banner. Click it, complete the Google consent once
|
||||
more, and syncing resumes. This is the price of avoiding CASA — it is expected,
|
||||
not a bug. (Publishing the client past verification removes the 7-day expiry but
|
||||
triggers the verification + CASA requirements above.)
|
||||
|
||||
## Source overlap
|
||||
|
||||
If Google Health and another source (Withings, WHOOP, Apple Health) both supply
|
||||
the same vital — resting heart rate, blood oxygen, body temperature or
|
||||
respiratory rate — you may see both values until a future update settles on a
|
||||
single preferred source.
|
||||
the same vital — resting heart rate, blood oxygen, respiratory rate — the source
|
||||
priority ladder in **Settings → Integrations → Sources** decides which one wins
|
||||
per metric.
|
||||
|
||||
## Disconnect
|
||||
|
||||
The Settings **Disconnect** button revokes the stored tokens and removes the
|
||||
connection. Previously synced measurements stay in the database.
|
||||
The Settings **Disconnect** button removes the connection and its stored tokens.
|
||||
Previously synced measurements stay in the database.
|
||||
@@ -4822,6 +4822,45 @@
|
||||
"fitbitBackfillInProgress": "Dein Fitbit-Verlauf wird im Hintergrund importiert…",
|
||||
"fitbitOauthConnected": "Fitbit verbunden — die erste Synchronisierung startet im Hintergrund.",
|
||||
"fitbitOauthFailed": "Fitbit-Verbindung fehlgeschlagen",
|
||||
"googleHealth": "Google Health",
|
||||
"googleHealthTag": "Fitbit & Pixel",
|
||||
"googleHealthBetaBadge": "Beta",
|
||||
"googleHealthDescription": "Verbinde Fitbit, Pixel Watch und Fitbit Air über dein Google-Konto — Gewicht, Herzfrequenz, Schlaf und Aktivität. Das ersetzt die klassische Fitbit-Verbindung, die Google im September 2026 abschaltet.",
|
||||
"googleHealthDescriptionSecondary": "Du registrierst deinen eigenen Google-Cloud-Client und hinterlegst die Zugangsdaten hier. Stress und Erholung liefert die API nicht, und Google kann dich von Zeit zu Zeit erneut um Zustimmung bitten. Neu bei der Einrichtung? Folge der",
|
||||
"googleHealthCredentials": "API-Zugangsdaten",
|
||||
"googleHealthClientId": "Client-ID",
|
||||
"googleHealthClientSecret": "Client-Secret",
|
||||
"googleHealthCredentialsSaved": "Zugangsdaten gespeichert",
|
||||
"googleHealthCredentialsSavedPlaceholder": "Gespeichert — zum Ersetzen neu eingeben",
|
||||
"googleHealthCredentialsSavedPlaceholderSecret": "Gespeichert — zum Ersetzen neu eingeben",
|
||||
"googleHealthSaveCredentials": "Speichern",
|
||||
"googleHealthSync": "Jetzt synchronisieren",
|
||||
"googleHealthFullSync": "Alle Daten synchronisieren",
|
||||
"googleHealthFullSyncTitle": "Vollständige Synchronisierung?",
|
||||
"googleHealthFullSyncDescription": "Der gesamte verfügbare Google-Health-Verlauf wird vollständig synchronisiert. Je nach Verlauf kann das eine Weile dauern.",
|
||||
"googleHealthSyncResult": "{count} Messwerte synchronisiert",
|
||||
"googleHealthSyncFailed": "Synchronisierung fehlgeschlagen",
|
||||
"googleHealthSynchronize": "Synchronisieren",
|
||||
"googleHealthDisconnect": "Trennen",
|
||||
"googleHealthDisconnectTitle": "Google Health trennen?",
|
||||
"googleHealthDisconnectDescription": "Die Verbindung zu Google Health wird getrennt. Bereits synchronisierte Daten bleiben erhalten.",
|
||||
"googleHealthConnect": "Mit Google verbinden",
|
||||
"googleHealthNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um Google Health zu verbinden.",
|
||||
"googleHealthBackfillInProgress": "Dein Google-Health-Verlauf wird im Hintergrund importiert…",
|
||||
"googleHealthReauthBanner": "Google hat die Verbindung ablaufen lassen — das passiert regelmäßig, solange deine OAuth-App im Testmodus bleibt. Verbinde neu, um die Synchronisierung fortzusetzen.",
|
||||
"googleHealthReconnect": "Neu verbinden",
|
||||
"googleHealthOauthConnected": "Google Health verbunden — die erste Synchronisierung startet im Hintergrund.",
|
||||
"googleHealthOauthFailed": "Verbindung zu Google Health fehlgeschlagen",
|
||||
"googleHealthOauthError": {
|
||||
"csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.",
|
||||
"state": "Die Antwort von Google passte zu keiner offenen Verbindungsanfrage. Bitte starte die Verbindung erneut.",
|
||||
"expired": "Die Verbindungsanfrage ist abgelaufen, bevor sie abgeschlossen wurde. Bitte starte die Verbindung erneut.",
|
||||
"cross_user": "Die Antwort gehört zu einer Anfrage eines anderen Kontos. Bitte starte die Verbindung von deinem eigenen Konto aus neu.",
|
||||
"nocode": "Google hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im Google-Dialog.",
|
||||
"nocreds": "Google Health ist nicht konfiguriert. Hinterlege oben deine Google-OAuth-Zugangsdaten und versuche es erneut.",
|
||||
"token": "Der Token-Austausch mit Google ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
|
||||
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
|
||||
},
|
||||
"fitbitOauthError": {
|
||||
"csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.",
|
||||
"state": "Die Antwort von Fitbit passte zu keiner offenen Verbindungsanfrage. Bitte starte die Verbindung erneut.",
|
||||
|
||||
@@ -4822,6 +4822,45 @@
|
||||
"fitbitBackfillInProgress": "Importing your Fitbit history in the background…",
|
||||
"fitbitOauthConnected": "Fitbit connected — the first sync starts in the background.",
|
||||
"fitbitOauthFailed": "Fitbit connection failed",
|
||||
"googleHealth": "Google Health",
|
||||
"googleHealthTag": "Fitbit & Pixel",
|
||||
"googleHealthBetaBadge": "Beta",
|
||||
"googleHealthDescription": "Connect Fitbit, Pixel Watch and Fitbit Air through your Google account — weight, heart rate, sleep and activity. This replaces the classic Fitbit connection, which Google retires in September 2026.",
|
||||
"googleHealthDescriptionSecondary": "You register your own Google Cloud client and bring the credentials here. Stress and readiness are not offered by the API, and Google may ask you to reconnect from time to time. New to the setup? Follow the",
|
||||
"googleHealthCredentials": "API Credentials",
|
||||
"googleHealthClientId": "Client ID",
|
||||
"googleHealthClientSecret": "Client Secret",
|
||||
"googleHealthCredentialsSaved": "Credentials saved",
|
||||
"googleHealthCredentialsSavedPlaceholder": "Saved — enter new to replace",
|
||||
"googleHealthCredentialsSavedPlaceholderSecret": "Saved — enter new to replace",
|
||||
"googleHealthSaveCredentials": "Save",
|
||||
"googleHealthSync": "Sync now",
|
||||
"googleHealthFullSync": "Sync all data",
|
||||
"googleHealthFullSyncTitle": "Full synchronization?",
|
||||
"googleHealthFullSyncDescription": "All available Google Health history will be fully synchronized. This may take some time depending on your history.",
|
||||
"googleHealthSyncResult": "{count} measurements synchronized",
|
||||
"googleHealthSyncFailed": "Sync failed",
|
||||
"googleHealthSynchronize": "Synchronize",
|
||||
"googleHealthDisconnect": "Disconnect",
|
||||
"googleHealthDisconnectTitle": "Disconnect Google Health?",
|
||||
"googleHealthDisconnectDescription": "The connection to Google Health will be disconnected. Previously synced data will be preserved.",
|
||||
"googleHealthConnect": "Connect with Google",
|
||||
"googleHealthNoCredentials": "Please enter your API credentials above to connect Google Health.",
|
||||
"googleHealthBackfillInProgress": "Importing your Google Health history in the background…",
|
||||
"googleHealthReauthBanner": "Google has expired the connection — this happens periodically while your OAuth app stays in Testing mode. Reconnect to resume syncing.",
|
||||
"googleHealthReconnect": "Reconnect",
|
||||
"googleHealthOauthConnected": "Google Health connected — the first sync starts in the background.",
|
||||
"googleHealthOauthFailed": "Google Health connection failed",
|
||||
"googleHealthOauthError": {
|
||||
"csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.",
|
||||
"state": "The response from Google did not match any open connection request. Please start the connection again.",
|
||||
"expired": "The connection request expired before it completed. Please start the connection again.",
|
||||
"cross_user": "The response belongs to a request from a different account. Please restart the connection from your own account.",
|
||||
"nocode": "Google did not return an authorization code. Try again and allow access in the Google dialog.",
|
||||
"nocreds": "Google Health is not configured. Add your Google OAuth credentials above and try again.",
|
||||
"token": "The token exchange with Google failed. Please start the connection again.",
|
||||
"generic": "Unknown error while connecting. Please start the connection again."
|
||||
},
|
||||
"fitbitOauthError": {
|
||||
"csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.",
|
||||
"state": "The response from Fitbit did not match any open connection request. Please start the connection again.",
|
||||
|
||||
@@ -4822,6 +4822,45 @@
|
||||
"fitbitBackfillInProgress": "Importando tu historial de Fitbit en segundo plano…",
|
||||
"fitbitOauthConnected": "Fitbit conectado: la primera sincronización empieza en segundo plano.",
|
||||
"fitbitOauthFailed": "Error al conectar con Fitbit",
|
||||
"googleHealth": "Google Health",
|
||||
"googleHealthTag": "Fitbit y Pixel",
|
||||
"googleHealthBetaBadge": "Beta",
|
||||
"googleHealthDescription": "Conecta Fitbit, Pixel Watch y Fitbit Air a través de tu cuenta de Google: peso, frecuencia cardíaca, sueño y actividad. Sustituye a la conexión clásica de Fitbit, que Google retira en septiembre de 2026.",
|
||||
"googleHealthDescriptionSecondary": "Registras tu propio cliente de Google Cloud y traes aquí las credenciales. La API no ofrece estrés ni preparación, y Google puede pedirte que vuelvas a conectar de vez en cuando. ¿Es tu primera vez? Sigue la",
|
||||
"googleHealthCredentials": "Credenciales de API",
|
||||
"googleHealthClientId": "ID de cliente",
|
||||
"googleHealthClientSecret": "Secreto de cliente",
|
||||
"googleHealthCredentialsSaved": "Credenciales guardadas",
|
||||
"googleHealthCredentialsSavedPlaceholder": "Guardado — introduce nuevas para reemplazar",
|
||||
"googleHealthCredentialsSavedPlaceholderSecret": "Guardado — introduce nuevas para reemplazar",
|
||||
"googleHealthSaveCredentials": "Guardar",
|
||||
"googleHealthSync": "Sincronizar ahora",
|
||||
"googleHealthFullSync": "Sincronizar todos los datos",
|
||||
"googleHealthFullSyncTitle": "¿Sincronización completa?",
|
||||
"googleHealthFullSyncDescription": "Se sincronizará por completo todo el historial disponible de Google Health. Puede tardar según tu historial.",
|
||||
"googleHealthSyncResult": "{count} mediciones sincronizadas",
|
||||
"googleHealthSyncFailed": "Error de sincronización",
|
||||
"googleHealthSynchronize": "Sincronizar",
|
||||
"googleHealthDisconnect": "Desconectar",
|
||||
"googleHealthDisconnectTitle": "¿Desconectar Google Health?",
|
||||
"googleHealthDisconnectDescription": "Se desconectará la conexión con Google Health. Los datos ya sincronizados se conservarán.",
|
||||
"googleHealthConnect": "Conectar con Google",
|
||||
"googleHealthNoCredentials": "Introduce tus credenciales de API arriba para conectar Google Health.",
|
||||
"googleHealthBackfillInProgress": "Importando tu historial de Google Health en segundo plano…",
|
||||
"googleHealthReauthBanner": "Google ha caducado la conexión: ocurre de forma periódica mientras tu app de OAuth permanece en modo de prueba. Vuelve a conectar para reanudar la sincronización.",
|
||||
"googleHealthReconnect": "Volver a conectar",
|
||||
"googleHealthOauthConnected": "Google Health conectado: la primera sincronización empieza en segundo plano.",
|
||||
"googleHealthOauthFailed": "Error al conectar con Google Health",
|
||||
"googleHealthOauthError": {
|
||||
"csrf1": "La comprobación de seguridad falló porque faltaba la cookie del navegador. Vuelve a iniciar la conexión en el mismo navegador.",
|
||||
"state": "La respuesta de Google no coincidió con ninguna solicitud de conexión abierta. Vuelve a iniciar la conexión.",
|
||||
"expired": "La solicitud de conexión caducó antes de completarse. Vuelve a iniciar la conexión.",
|
||||
"cross_user": "La respuesta pertenece a una solicitud de otra cuenta. Reinicia la conexión desde tu propia cuenta.",
|
||||
"nocode": "Google no devolvió un código de autorización. Inténtalo de nuevo y concede el acceso en el diálogo de Google.",
|
||||
"nocreds": "Google Health no está configurado. Añade arriba tus credenciales de OAuth de Google e inténtalo de nuevo.",
|
||||
"token": "El intercambio de token con Google falló. Vuelve a iniciar la conexión.",
|
||||
"generic": "Error desconocido al conectar. Vuelve a iniciar la conexión."
|
||||
},
|
||||
"fitbitOauthError": {
|
||||
"csrf1": "La verificación de seguridad falló porque faltaba la cookie del navegador. Inicia la conexión de nuevo en el mismo navegador.",
|
||||
"state": "La respuesta de Fitbit no coincidió con ninguna solicitud de conexión abierta. Inicia la conexión de nuevo.",
|
||||
|
||||
@@ -4822,6 +4822,45 @@
|
||||
"fitbitBackfillInProgress": "Importation de votre historique Fitbit en arrière-plan…",
|
||||
"fitbitOauthConnected": "Fitbit connecté — la première synchronisation démarre en arrière-plan.",
|
||||
"fitbitOauthFailed": "Échec de la connexion à Fitbit",
|
||||
"googleHealth": "Google Health",
|
||||
"googleHealthTag": "Fitbit et Pixel",
|
||||
"googleHealthBetaBadge": "Bêta",
|
||||
"googleHealthDescription": "Connecte Fitbit, Pixel Watch et Fitbit Air via ton compte Google — poids, fréquence cardiaque, sommeil et activité. Cela remplace la connexion Fitbit classique, que Google arrête en septembre 2026.",
|
||||
"googleHealthDescriptionSecondary": "Tu enregistres ton propre client Google Cloud et saisis les identifiants ici. L'API ne fournit ni stress ni disponibilité, et Google peut te demander de te reconnecter de temps en temps. Première configuration ? Suis le",
|
||||
"googleHealthCredentials": "Identifiants d'API",
|
||||
"googleHealthClientId": "ID client",
|
||||
"googleHealthClientSecret": "Secret client",
|
||||
"googleHealthCredentialsSaved": "Identifiants enregistrés",
|
||||
"googleHealthCredentialsSavedPlaceholder": "Enregistré — saisir de nouveaux pour remplacer",
|
||||
"googleHealthCredentialsSavedPlaceholderSecret": "Enregistré — saisir de nouveaux pour remplacer",
|
||||
"googleHealthSaveCredentials": "Enregistrer",
|
||||
"googleHealthSync": "Synchroniser maintenant",
|
||||
"googleHealthFullSync": "Synchroniser toutes les données",
|
||||
"googleHealthFullSyncTitle": "Synchronisation complète ?",
|
||||
"googleHealthFullSyncDescription": "Tout l'historique Google Health disponible sera entièrement synchronisé. Cela peut prendre un certain temps selon ton historique.",
|
||||
"googleHealthSyncResult": "{count} mesures synchronisées",
|
||||
"googleHealthSyncFailed": "Échec de la synchronisation",
|
||||
"googleHealthSynchronize": "Synchroniser",
|
||||
"googleHealthDisconnect": "Déconnecter",
|
||||
"googleHealthDisconnectTitle": "Déconnecter Google Health ?",
|
||||
"googleHealthDisconnectDescription": "La connexion à Google Health sera déconnectée. Les données déjà synchronisées seront conservées.",
|
||||
"googleHealthConnect": "Se connecter avec Google",
|
||||
"googleHealthNoCredentials": "Saisis tes identifiants d'API ci-dessus pour connecter Google Health.",
|
||||
"googleHealthBackfillInProgress": "Importation de ton historique Google Health en arrière-plan…",
|
||||
"googleHealthReauthBanner": "Google a expiré la connexion — cela arrive régulièrement tant que ton application OAuth reste en mode Test. Reconnecte-toi pour reprendre la synchronisation.",
|
||||
"googleHealthReconnect": "Se reconnecter",
|
||||
"googleHealthOauthConnected": "Google Health connecté — la première synchronisation démarre en arrière-plan.",
|
||||
"googleHealthOauthFailed": "Échec de la connexion à Google Health",
|
||||
"googleHealthOauthError": {
|
||||
"csrf1": "La vérification de sécurité a échoué car le cookie du navigateur était manquant. Relance la connexion dans le même navigateur.",
|
||||
"state": "La réponse de Google ne correspondait à aucune demande de connexion ouverte. Relance la connexion.",
|
||||
"expired": "La demande de connexion a expiré avant d'aboutir. Relance la connexion.",
|
||||
"cross_user": "La réponse appartient à une demande d'un autre compte. Relance la connexion depuis ton propre compte.",
|
||||
"nocode": "Google n'a pas renvoyé de code d'autorisation. Réessaie et autorise l'accès dans la fenêtre Google.",
|
||||
"nocreds": "Google Health n'est pas configuré. Ajoute tes identifiants OAuth Google ci-dessus et réessaie.",
|
||||
"token": "L'échange de jeton avec Google a échoué. Relance la connexion.",
|
||||
"generic": "Erreur inconnue lors de la connexion. Relance la connexion."
|
||||
},
|
||||
"fitbitOauthError": {
|
||||
"csrf1": "La vérification de sécurité a échoué car le cookie du navigateur était absent. Veuillez relancer la connexion dans le même navigateur.",
|
||||
"state": "La réponse de Fitbit ne correspondait à aucune demande de connexion en cours. Veuillez relancer la connexion.",
|
||||
|
||||
@@ -4822,6 +4822,45 @@
|
||||
"fitbitBackfillInProgress": "Importazione dello storico Fitbit in background…",
|
||||
"fitbitOauthConnected": "Fitbit collegato — la prima sincronizzazione parte in background.",
|
||||
"fitbitOauthFailed": "Connessione a Fitbit non riuscita",
|
||||
"googleHealth": "Google Health",
|
||||
"googleHealthTag": "Fitbit e Pixel",
|
||||
"googleHealthBetaBadge": "Beta",
|
||||
"googleHealthDescription": "Collega Fitbit, Pixel Watch e Fitbit Air tramite il tuo account Google — peso, frequenza cardiaca, sonno e attività. Sostituisce la connessione Fitbit classica, che Google ritira a settembre 2026.",
|
||||
"googleHealthDescriptionSecondary": "Registri il tuo client Google Cloud e inserisci qui le credenziali. L'API non offre stress né prontezza, e Google può chiederti di riconnetterti di tanto in tanto. Prima configurazione? Segui la",
|
||||
"googleHealthCredentials": "Credenziali API",
|
||||
"googleHealthClientId": "ID client",
|
||||
"googleHealthClientSecret": "Client secret",
|
||||
"googleHealthCredentialsSaved": "Credenziali salvate",
|
||||
"googleHealthCredentialsSavedPlaceholder": "Salvate — inseriscine di nuove per sostituirle",
|
||||
"googleHealthCredentialsSavedPlaceholderSecret": "Salvate — inseriscine di nuove per sostituirle",
|
||||
"googleHealthSaveCredentials": "Salva",
|
||||
"googleHealthSync": "Sincronizza ora",
|
||||
"googleHealthFullSync": "Sincronizza tutti i dati",
|
||||
"googleHealthFullSyncTitle": "Sincronizzazione completa?",
|
||||
"googleHealthFullSyncDescription": "Tutta la cronologia disponibile di Google Health verrà sincronizzata completamente. Può richiedere del tempo in base alla tua cronologia.",
|
||||
"googleHealthSyncResult": "{count} misurazioni sincronizzate",
|
||||
"googleHealthSyncFailed": "Sincronizzazione non riuscita",
|
||||
"googleHealthSynchronize": "Sincronizza",
|
||||
"googleHealthDisconnect": "Disconnetti",
|
||||
"googleHealthDisconnectTitle": "Disconnettere Google Health?",
|
||||
"googleHealthDisconnectDescription": "La connessione a Google Health verrà disconnessa. I dati già sincronizzati saranno conservati.",
|
||||
"googleHealthConnect": "Connetti con Google",
|
||||
"googleHealthNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere Google Health.",
|
||||
"googleHealthBackfillInProgress": "Importazione della cronologia di Google Health in background…",
|
||||
"googleHealthReauthBanner": "Google ha fatto scadere la connessione — accade periodicamente finché la tua app OAuth resta in modalità Test. Riconnettiti per riprendere la sincronizzazione.",
|
||||
"googleHealthReconnect": "Riconnetti",
|
||||
"googleHealthOauthConnected": "Google Health connesso — la prima sincronizzazione parte in background.",
|
||||
"googleHealthOauthFailed": "Connessione a Google Health non riuscita",
|
||||
"googleHealthOauthError": {
|
||||
"csrf1": "Il controllo di sicurezza non è riuscito perché mancava il cookie del browser. Riavvia la connessione nello stesso browser.",
|
||||
"state": "La risposta di Google non corrispondeva a nessuna richiesta di connessione aperta. Riavvia la connessione.",
|
||||
"expired": "La richiesta di connessione è scaduta prima del completamento. Riavvia la connessione.",
|
||||
"cross_user": "La risposta appartiene a una richiesta di un altro account. Riavvia la connessione dal tuo account.",
|
||||
"nocode": "Google non ha restituito un codice di autorizzazione. Riprova e consenti l'accesso nella finestra di Google.",
|
||||
"nocreds": "Google Health non è configurato. Aggiungi qui sopra le tue credenziali OAuth di Google e riprova.",
|
||||
"token": "Lo scambio del token con Google non è riuscito. Riavvia la connessione.",
|
||||
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
|
||||
},
|
||||
"fitbitOauthError": {
|
||||
"csrf1": "Il controllo di sicurezza è fallito perché il cookie del browser era assente. Riavvia la connessione nello stesso browser.",
|
||||
"state": "La risposta di Fitbit non corrispondeva a nessuna richiesta di connessione aperta. Riavvia la connessione.",
|
||||
|
||||
@@ -4822,6 +4822,45 @@
|
||||
"fitbitBackfillInProgress": "Importowanie historii Fitbit w tle…",
|
||||
"fitbitOauthConnected": "Fitbit połączony — pierwsza synchronizacja startuje w tle.",
|
||||
"fitbitOauthFailed": "Połączenie z Fitbit nie powiodło się",
|
||||
"googleHealth": "Google Health",
|
||||
"googleHealthTag": "Fitbit i Pixel",
|
||||
"googleHealthBetaBadge": "Beta",
|
||||
"googleHealthDescription": "Połącz Fitbit, Pixel Watch i Fitbit Air przez konto Google — waga, tętno, sen i aktywność. Zastępuje to klasyczne połączenie Fitbit, które Google wyłącza we wrześniu 2026.",
|
||||
"googleHealthDescriptionSecondary": "Rejestrujesz własnego klienta Google Cloud i wprowadzasz tutaj dane logowania. API nie udostępnia stresu ani gotowości, a Google może od czasu do czasu poprosić o ponowne połączenie. Pierwsza konfiguracja? Skorzystaj z",
|
||||
"googleHealthCredentials": "Dane API",
|
||||
"googleHealthClientId": "Identyfikator klienta",
|
||||
"googleHealthClientSecret": "Sekret klienta",
|
||||
"googleHealthCredentialsSaved": "Dane logowania zapisane",
|
||||
"googleHealthCredentialsSavedPlaceholder": "Zapisane — wpisz nowe, aby zastąpić",
|
||||
"googleHealthCredentialsSavedPlaceholderSecret": "Zapisane — wpisz nowe, aby zastąpić",
|
||||
"googleHealthSaveCredentials": "Zapisz",
|
||||
"googleHealthSync": "Synchronizuj teraz",
|
||||
"googleHealthFullSync": "Synchronizuj wszystkie dane",
|
||||
"googleHealthFullSyncTitle": "Pełna synchronizacja?",
|
||||
"googleHealthFullSyncDescription": "Cała dostępna historia Google Health zostanie w pełni zsynchronizowana. Może to chwilę potrwać w zależności od historii.",
|
||||
"googleHealthSyncResult": "Zsynchronizowano pomiary: {count}",
|
||||
"googleHealthSyncFailed": "Synchronizacja nie powiodła się",
|
||||
"googleHealthSynchronize": "Synchronizuj",
|
||||
"googleHealthDisconnect": "Rozłącz",
|
||||
"googleHealthDisconnectTitle": "Rozłączyć Google Health?",
|
||||
"googleHealthDisconnectDescription": "Połączenie z Google Health zostanie rozłączone. Wcześniej zsynchronizowane dane zostaną zachowane.",
|
||||
"googleHealthConnect": "Połącz z Google",
|
||||
"googleHealthNoCredentials": "Wprowadź powyżej swoje dane API, aby połączyć Google Health.",
|
||||
"googleHealthBackfillInProgress": "Importowanie historii Google Health w tle…",
|
||||
"googleHealthReauthBanner": "Google unieważnił połączenie — dzieje się to okresowo, dopóki Twoja aplikacja OAuth pozostaje w trybie testowym. Połącz ponownie, aby wznowić synchronizację.",
|
||||
"googleHealthReconnect": "Połącz ponownie",
|
||||
"googleHealthOauthConnected": "Google Health połączony — pierwsza synchronizacja startuje w tle.",
|
||||
"googleHealthOauthFailed": "Połączenie z Google Health nie powiodło się",
|
||||
"googleHealthOauthError": {
|
||||
"csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.",
|
||||
"state": "Odpowiedź od Google nie pasowała do żadnego otwartego żądania połączenia. Rozpocznij połączenie ponownie.",
|
||||
"expired": "Żądanie połączenia wygasło przed zakończeniem. Rozpocznij połączenie ponownie.",
|
||||
"cross_user": "Odpowiedź należy do żądania z innego konta. Rozpocznij połączenie ponownie z własnego konta.",
|
||||
"nocode": "Google nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Google.",
|
||||
"nocreds": "Google Health nie jest skonfigurowany. Dodaj powyżej swoje dane OAuth Google i spróbuj ponownie.",
|
||||
"token": "Wymiana tokenu z Google nie powiodła się. Rozpocznij połączenie ponownie.",
|
||||
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
|
||||
},
|
||||
"fitbitOauthError": {
|
||||
"csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.",
|
||||
"state": "Odpowiedź z Fitbit nie pasowała do żadnego otwartego żądania połączenia. Rozpocznij połączenie ponownie.",
|
||||
|
||||
@@ -93,6 +93,7 @@ const LEGIT_IDENTICAL = new Set<string>([
|
||||
"measurements.typeVo2Max",
|
||||
"settings.sections.sources.metrics.vo2Max",
|
||||
// Brand / product / proper nouns.
|
||||
"settings.googleHealth",
|
||||
"admin.glitchtipDsn",
|
||||
"admin.integrationWebPush",
|
||||
"admin.offlineGeoEnabled",
|
||||
|
||||
@@ -131,10 +131,10 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
});
|
||||
|
||||
const html = render();
|
||||
// Exactly one pill per card → 6 pills total (Withings, WHOOP,
|
||||
// Fitbit / Google Health, Polar, Oura, Nightscout). The moodLog integration
|
||||
// was removed; Polar + Oura (F4) were added in v1.17.0.
|
||||
expect(count(html, 'data-testid="integration-status-pill"')).toBe(6);
|
||||
// Exactly one pill per card → 7 pills total (Withings, WHOOP, Fitbit,
|
||||
// Google Health, Polar, Oura, Nightscout). The moodLog integration was
|
||||
// removed; Polar + Oura (F4) were added in v1.17.0; Google Health in v1.27.0.
|
||||
expect(count(html, 'data-testid="integration-status-pill"')).toBe(7);
|
||||
// The redundant banner from v1.4.15 is gone.
|
||||
expect(html).not.toContain('data-testid="integration-status-banner"');
|
||||
// Card-body "letzter Sync" repetition is gone — no
|
||||
@@ -300,8 +300,8 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
const html = render();
|
||||
// Every integration card includes the section divider data-testid so the
|
||||
// header → body separation is visually consistent (Withings, WHOOP,
|
||||
// Fitbit / Google Health). The moodLog integration was removed.
|
||||
expect(count(html, 'data-testid="integration-card-divider"')).toBe(3);
|
||||
// Fitbit, Google Health). The moodLog integration was removed.
|
||||
expect(count(html, 'data-testid="integration-card-divider"')).toBe(4);
|
||||
});
|
||||
|
||||
// v1.17.1 — every integration card carries the same discreet "Setup guide"
|
||||
@@ -326,12 +326,13 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
});
|
||||
|
||||
const html = render();
|
||||
// One setup-guide link per card → six providers.
|
||||
expect(count(html, 'data-slot="integration-setup-guide"')).toBe(6);
|
||||
// One setup-guide link per card → seven providers.
|
||||
expect(count(html, 'data-slot="integration-setup-guide"')).toBe(7);
|
||||
for (const provider of [
|
||||
"withings",
|
||||
"whoop",
|
||||
"fitbit",
|
||||
"google-health",
|
||||
"polar",
|
||||
"oura",
|
||||
"nightscout",
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { FitbitCard } from "@/components/settings/integrations/fitbit-card";
|
||||
import { GoogleHealthCard } from "@/components/settings/integrations/google-health-card";
|
||||
import { NightscoutCard } from "@/components/settings/integrations/nightscout-card";
|
||||
import type { OAuthProviderStatus } from "@/components/settings/integrations/oauth-provider-card";
|
||||
import { OuraCard } from "@/components/settings/integrations/oura-card";
|
||||
@@ -60,7 +61,17 @@ type WithingsOauthOutcome =
|
||||
* per-card status query; WHOOP/Fitbit read off the consolidated envelope — both
|
||||
* are invalidated on a successful return so the card repaints either way.
|
||||
*/
|
||||
const OAUTH_OUTCOME_PROVIDERS = ["polar", "oura", "whoop", "fitbit"] as const;
|
||||
// v1.27.0 — `googleHealth` joins the outcome set. Its callback redirects with
|
||||
// `?googleHealth=connected|error&reason=<tag>` (the fitbit→googleHealth
|
||||
// identifier rename), and the i18n toast keys resolve under the same camelCase
|
||||
// prefix (`settings.googleHealthOauth*`).
|
||||
const OAUTH_OUTCOME_PROVIDERS = [
|
||||
"polar",
|
||||
"oura",
|
||||
"whoop",
|
||||
"fitbit",
|
||||
"googleHealth",
|
||||
] as const;
|
||||
type OAuthOutcomeProvider = (typeof OAUTH_OUTCOME_PROVIDERS)[number];
|
||||
|
||||
const OAUTH_OUTCOME_KEYS: Record<
|
||||
@@ -71,6 +82,7 @@ const OAUTH_OUTCOME_KEYS: Record<
|
||||
oura: queryKeys.oura,
|
||||
whoop: queryKeys.whoop,
|
||||
fitbit: queryKeys.fitbit,
|
||||
googleHealth: queryKeys.googleHealth,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -240,6 +252,9 @@ export function ConnectionsPanel() {
|
||||
const withingsViewModel = pickStatus(integrationStatus, "withings");
|
||||
const whoopViewModel = pickStatus(integrationStatus, "whoop");
|
||||
const fitbitViewModel = pickStatus(integrationStatus, "fitbit");
|
||||
// v1.27.0 — Google Health reads off the same consolidated envelope; the
|
||||
// ledger integration key is the hyphenated `google-health`.
|
||||
const googleHealthViewModel = pickStatus(integrationStatus, "google-health");
|
||||
// v1.17.1 — Polar/Oura now read off the same consolidated envelope; the cards
|
||||
// no longer fire their own /api/<provider>/status round-trip.
|
||||
const polarViewModel = toOAuthStatus(pickStatus(integrationStatus, "polar"));
|
||||
@@ -250,6 +265,7 @@ export function ConnectionsPanel() {
|
||||
<WithingsCard viewModel={withingsViewModel} />
|
||||
<WhoopCard viewModel={whoopViewModel} />
|
||||
<FitbitCard viewModel={fitbitViewModel} />
|
||||
<GoogleHealthCard viewModel={googleHealthViewModel} />
|
||||
<PolarCard enabled={isAuthenticated} viewModel={polarViewModel} />
|
||||
<OuraCard enabled={isAuthenticated} viewModel={ouraViewModel} />
|
||||
<NightscoutCard enabled={isAuthenticated} />
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
"use client";
|
||||
|
||||
// v1.27.0 — Google Health (Fitbit + Pixel Watch + Fitbit Air) card. Reads
|
||||
// Fitbit and Pixel Watch data through the successor Google Health API — a
|
||||
// separate, coexisting integration from the classic `fitbit` transport, which
|
||||
// sunsets Sept 2026. Mirrors the Fitbit card anatomy: a BYO Google-Cloud
|
||||
// client-id/secret form first, then an OAuth connect, then the
|
||||
// sync/test/disconnect action row + parked-resume banner. Status reads off the
|
||||
// consolidated /api/integrations/status envelope (no per-card round-trip).
|
||||
//
|
||||
// One thing this card carries that the classic Fitbit card does not: a distinct
|
||||
// RE-CONSENT CTA. Google expires the refresh token after 7 days while the
|
||||
// operator's OAuth client stays in "Testing" publishing mode (the CASA-free
|
||||
// path), so a connected user is periodically pushed back through OAuth. When the
|
||||
// envelope reports `needsReauth`, the card surfaces a clear "Reconnect" banner
|
||||
// separate from the connect/disconnect state.
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Link2,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
RotateCw,
|
||||
Save,
|
||||
Unlink,
|
||||
Watch,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
|
||||
import type { IntegrationPillState } from "@/components/settings/integration-status-pill";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
invalidateKeys,
|
||||
measurementDependentKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
|
||||
import {
|
||||
IntegrationErrorMessage,
|
||||
pillStateFor,
|
||||
type IntegrationStatusViewModel,
|
||||
} from "./shared";
|
||||
import { IntegrationCardDescription } from "./setup-guide-link";
|
||||
|
||||
export function GoogleHealthCard({
|
||||
viewModel,
|
||||
}: {
|
||||
viewModel: IntegrationStatusViewModel | undefined;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncMsg, setSyncMsg] = useState<string | null>(null);
|
||||
const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>(
|
||||
null,
|
||||
);
|
||||
const [clientId, setClientId] = useState("");
|
||||
const [clientSecret, setClientSecret] = useState("");
|
||||
const [credsSaving, setCredsSaving] = useState(false);
|
||||
const [credsMsg, setCredsMsg] = useState<string | null>(null);
|
||||
const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>(
|
||||
null,
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const status = viewModel;
|
||||
|
||||
const disconnect = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiPost("/api/google-health/disconnect");
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.googleHealth() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.integrationsStatus(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Clear a parked integration via the resume endpoint. The CTA is rendered
|
||||
// inside the parked banner below; success invalidates both the per-card
|
||||
// status and the cross-integration envelope so any other view picks up the
|
||||
// change on its next focus.
|
||||
const resume = useMutation({
|
||||
mutationFn: async () => {
|
||||
return apiPost<{ resumed: boolean; wasParked: boolean }>(
|
||||
"/api/integrations/google-health/resume",
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.googleHealth() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.integrationsStatus(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
async function handleSync(fullSync = false) {
|
||||
setSyncing(true);
|
||||
setSyncMsg(null);
|
||||
setSyncMsgType(null);
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/google-health/sync", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fullSync }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (res.ok) {
|
||||
setSyncMsg(
|
||||
t("settings.googleHealthSyncResult", { count: json.data.imported }),
|
||||
);
|
||||
setSyncMsgType("success");
|
||||
void invalidateKeys(queryClient, measurementDependentKeys);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.googleHealth() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.integrationsStatus(),
|
||||
});
|
||||
} else {
|
||||
setSyncMsg(json.error || t("settings.googleHealthSyncFailed"));
|
||||
setSyncMsgType("error");
|
||||
}
|
||||
} catch {
|
||||
setSyncMsg(t("settings.googleHealthSyncFailed"));
|
||||
setSyncMsgType("error");
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveCredentials(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setCredsSaving(true);
|
||||
setCredsMsg(null);
|
||||
setCredsMsgType(null);
|
||||
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/google-health/credentials", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
clientId: clientId.trim(),
|
||||
clientSecret: clientSecret.trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setCredsMsg(t("settings.googleHealthCredentialsSaved"));
|
||||
setCredsMsgType("success");
|
||||
setClientId("");
|
||||
setClientSecret("");
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.googleHealth() });
|
||||
} else {
|
||||
try {
|
||||
const json = await res.json();
|
||||
setCredsMsg(json.error || t("settings.savingError"));
|
||||
} catch {
|
||||
setCredsMsg(t("settings.savingError"));
|
||||
}
|
||||
setCredsMsgType("error");
|
||||
}
|
||||
} catch {
|
||||
setCredsMsg(t("common.networkError"));
|
||||
setCredsMsgType("error");
|
||||
}
|
||||
setCredsSaving(false);
|
||||
}
|
||||
|
||||
const pillState: IntegrationPillState = status?.connected
|
||||
? pillStateFor(viewModel)
|
||||
: "disconnected";
|
||||
const pillLastSyncAt =
|
||||
status?.legacyLastSyncedAt ?? viewModel?.lastSuccessAt ?? null;
|
||||
const errorMessage =
|
||||
(pillState === "error" || pillState === "parked") && viewModel?.lastError
|
||||
? viewModel.lastError
|
||||
: null;
|
||||
// The re-consent banner only makes sense while the connection still exists —
|
||||
// a disconnected card has no token to renew. `needsReauth` is the 7-day
|
||||
// Testing-mode expiry (or a revoked grant) surfaced by the status route.
|
||||
const showReauth = Boolean(status?.connected && status?.needsReauth);
|
||||
|
||||
return (
|
||||
<SettingsCard data-testid="google-health-card">
|
||||
<SettingsCardHeader
|
||||
icon={Watch}
|
||||
title={t("settings.googleHealth")}
|
||||
titleAccessory={
|
||||
<>
|
||||
<span className="bg-muted text-foreground rounded-full px-2 py-0.5 text-[0.6875rem] font-medium">
|
||||
{t("settings.googleHealthTag")}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
data-testid="google-health-beta-badge"
|
||||
className="border-warning/50 text-warning"
|
||||
>
|
||||
{t("settings.googleHealthBetaBadge")}
|
||||
</Badge>
|
||||
</>
|
||||
}
|
||||
description={
|
||||
<IntegrationCardDescription
|
||||
i18nPrefix="settings.googleHealth"
|
||||
provider="google-health"
|
||||
/>
|
||||
}
|
||||
status={
|
||||
<IntegrationStatusPill
|
||||
state={pillState}
|
||||
lastSyncAt={pillLastSyncAt}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<hr
|
||||
data-testid="integration-card-divider"
|
||||
className="border-border/60 mt-4"
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
|
||||
|
||||
{/* Re-consent CTA — distinct from parked/disconnected. Google expires
|
||||
the refresh token after 7 days in "Testing" publishing mode (the
|
||||
CASA-free path), so a connected user is periodically pushed back
|
||||
through OAuth. Re-running connect mints a fresh grant. */}
|
||||
{showReauth && (
|
||||
<div
|
||||
data-testid="google-health-reauth-banner"
|
||||
className="border-warning/30 bg-warning/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="text-warning min-w-0 text-xs break-words">
|
||||
{t("settings.googleHealthReauthBanner")}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
window.location.href = "/api/google-health/connect";
|
||||
}}
|
||||
data-testid="google-health-reconnect-button"
|
||||
className="min-h-11"
|
||||
>
|
||||
<RotateCw className="h-3.5 w-3.5" />
|
||||
{t("settings.googleHealthReconnect")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parked-integration resume CTA — surfaces only when the row state is
|
||||
`parked` (>24h of persistent failures). */}
|
||||
{pillState === "parked" && (
|
||||
<div
|
||||
data-testid="google-health-parked-banner"
|
||||
className="border-warning/30 bg-warning/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="text-warning min-w-0 text-xs break-words">
|
||||
{t("settings.integrationPill.parkedReconnect")}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => resume.mutate()}
|
||||
disabled={resume.isPending}
|
||||
data-testid="google-health-resume-button"
|
||||
className="min-h-11"
|
||||
>
|
||||
{resume.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.integrationPill.resumeCta")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{resume.isError && (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive text-sm"
|
||||
data-testid="google-health-resume-error"
|
||||
>
|
||||
{t("settings.integrationPill.resumeError")}
|
||||
</p>
|
||||
)}
|
||||
{resume.isSuccess && resume.data?.wasParked && (
|
||||
<p
|
||||
role="status"
|
||||
className="text-success text-xs"
|
||||
data-testid="google-health-resume-success"
|
||||
>
|
||||
{t("settings.integrationPill.resumeSuccess")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t("settings.googleHealthCredentials")}
|
||||
</h3>
|
||||
<form onSubmit={handleSaveCredentials} className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="google-health-clientid">
|
||||
{t("settings.googleHealthClientId")}
|
||||
</Label>
|
||||
<Input
|
||||
id="google-health-clientid"
|
||||
value={clientId}
|
||||
onChange={(e) => setClientId(e.target.value)}
|
||||
placeholder={
|
||||
status?.configured
|
||||
? t("settings.googleHealthCredentialsSavedPlaceholder")
|
||||
: t("settings.googleHealthClientId")
|
||||
}
|
||||
maxLength={200}
|
||||
autoComplete="off"
|
||||
inputMode="text"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
enterKeyHint="next"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="google-health-secret">
|
||||
{t("settings.googleHealthClientSecret")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="google-health-secret"
|
||||
value={clientSecret}
|
||||
onChange={(e) => setClientSecret(e.target.value)}
|
||||
placeholder={
|
||||
status?.configured
|
||||
? t(
|
||||
"settings.googleHealthCredentialsSavedPlaceholderSecret",
|
||||
)
|
||||
: t("settings.googleHealthClientSecret")
|
||||
}
|
||||
maxLength={200}
|
||||
autoComplete="off"
|
||||
inputMode="text"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
enterKeyHint="done"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground/80 text-xs">
|
||||
{t("settings.integrationCredentialsHint")}
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
disabled={
|
||||
credsSaving || !clientId.trim() || !clientSecret.trim()
|
||||
}
|
||||
>
|
||||
{credsSaving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.googleHealthSaveCredentials")}
|
||||
</Button>
|
||||
</div>
|
||||
{credsMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${credsMsgType === "success" ? "text-success" : "text-destructive"}`}
|
||||
>
|
||||
{credsMsg}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{status?.connected ? (
|
||||
<>
|
||||
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
onClick={() => handleSync(false)}
|
||||
disabled={syncing}
|
||||
>
|
||||
{syncing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.googleHealthSync")}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
disabled={syncing}
|
||||
>
|
||||
{syncing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.googleHealthFullSync")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.googleHealthFullSyncTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.googleHealthFullSyncDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => handleSync(true)}>
|
||||
{t("settings.googleHealthSynchronize")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<TestConnectionButton
|
||||
endpoint="/api/integrations/google-health/test"
|
||||
disabled={!status?.connected}
|
||||
/>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive min-h-11"
|
||||
>
|
||||
<Unlink className="h-3.5 w-3.5" />
|
||||
{t("settings.googleHealthDisconnect")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.googleHealthDisconnectTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.googleHealthDisconnectDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => disconnect.mutate()}
|
||||
>
|
||||
{t("settings.googleHealthDisconnect")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
{status?.backfillCompleted === false && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.googleHealthBackfillInProgress")}
|
||||
</p>
|
||||
)}
|
||||
{syncMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${syncMsgType === "success" ? "text-success" : "text-destructive"}`}
|
||||
>
|
||||
{syncMsg}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : status?.configured ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
window.location.href = "/api/google-health/connect";
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
{t("settings.googleHealthConnect")}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="bg-muted/50 text-muted-foreground rounded-lg p-3 text-sm">
|
||||
{t("settings.googleHealthNoCredentials")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,14 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
export const INTEGRATION_DOCS_BASE = "https://docs.healthlog.dev/integrations";
|
||||
|
||||
export type IntegrationDocsProvider =
|
||||
"whoop" | "withings" | "fitbit" | "polar" | "oura" | "nightscout";
|
||||
| "whoop"
|
||||
| "withings"
|
||||
| "fitbit"
|
||||
| "polar"
|
||||
| "oura"
|
||||
| "nightscout"
|
||||
// v1.27.0 — Google Health runbook (docs.healthlog.dev/integrations/google-health).
|
||||
| "google-health";
|
||||
|
||||
export function integrationDocsHref(provider: IntegrationDocsProvider): string {
|
||||
return `${INTEGRATION_DOCS_BASE}/${provider}`;
|
||||
|
||||
@@ -19,7 +19,14 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
// IntegrationStatusPill now owns state + last-sync presentation, and
|
||||
// the actionable error message is shown inline above the action row.
|
||||
export type IntegrationKey =
|
||||
"withings" | "whoop" | "fitbit" | "moodlog" | "polar" | "oura";
|
||||
| "withings"
|
||||
| "whoop"
|
||||
| "fitbit"
|
||||
| "moodlog"
|
||||
| "polar"
|
||||
| "oura"
|
||||
// v1.27.0 — Google Health (Fitbit + Pixel Watch + Fitbit Air).
|
||||
| "google-health";
|
||||
export type IntegrationState =
|
||||
"connected" | "error_transient" | "error_reauth" | "disconnected" | "parked";
|
||||
|
||||
@@ -46,6 +53,11 @@ export interface IntegrationStatusViewModel {
|
||||
hasActivityScope?: boolean;
|
||||
// WHOOP / Fitbit backfill-in-progress note.
|
||||
backfillCompleted?: boolean | null;
|
||||
// v1.27.0 — Google Health surfaces `needsReauth` when the refresh token has
|
||||
// lapsed (the 7-day "Testing"-mode expiry, or a revoked grant). The card
|
||||
// paints a distinct re-consent CTA off this flag, separate from the parked
|
||||
// state. Server-populated on the `/api/integrations/status` envelope.
|
||||
needsReauth?: boolean;
|
||||
// moodLog webhook secret + entry count.
|
||||
webhookSecret?: string | null;
|
||||
entryCount?: number;
|
||||
|
||||
@@ -48,6 +48,10 @@ export type IntegrationKey =
|
||||
| "nightscout"
|
||||
| "polar"
|
||||
| "oura"
|
||||
// v1.27.0 — Google Health (Fitbit + Pixel Watch + Fitbit Air) via the
|
||||
// successor Google Health API. Separate connection from the classic
|
||||
// `fitbit` transport, which sunsets Sept 2026.
|
||||
| "google-health"
|
||||
| "moodlog";
|
||||
|
||||
/**
|
||||
@@ -809,6 +813,7 @@ const INTEGRATION_LABELS: Record<IntegrationKey, string> = {
|
||||
nightscout: "Nightscout",
|
||||
polar: "Polar",
|
||||
oura: "Oura",
|
||||
"google-health": "Google Health",
|
||||
moodlog: "moodLog",
|
||||
};
|
||||
|
||||
|
||||
@@ -36,6 +36,13 @@ export const integrationKeys = {
|
||||
*/
|
||||
fitbitStatus: () => ["fitbit", "status"] as const,
|
||||
|
||||
// v1.27.0 — Google Health (Fitbit + Pixel Watch + Fitbit Air) integration
|
||||
// card. Reads status off the consolidated /api/integrations/status envelope
|
||||
// (like Fitbit/WHOOP); the connect/disconnect/credentials/sync mutations
|
||||
// invalidate this key so the card repaints. Distinct hyphenated array so it
|
||||
// never collides with the classic `fitbit` key.
|
||||
googleHealth: () => ["google-health"] as const,
|
||||
|
||||
// v1.17.0 — Nightscout CGM integration card. Self-contained status read
|
||||
// (not the consolidated /api/integrations/status envelope), so the
|
||||
// connect/disconnect mutations invalidate this single key.
|
||||
|
||||
Reference in new issue
Block a user