mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge main into release/v1.27.0
Reconciles the v1.26.1 Fitbit setup-guide fix with the Google Health release: google-health.md stays the full runbook (with a pointer to the classic fitbit.md guide), the doc-parity guard now asserts the Google Health page against the google-health client's endpoints and scopes, and the changelog carries both sections.
This commit is contained in:
commit
5b63cb9ac9
6 files changed
+181
-11
No files matched your search
@@ -8,6 +8,12 @@
|
||||
|
||||
- A new Google Health connection reads your Fitbit, Pixel Watch, and Fitbit Air data through your Google account: heart rate and resting heart rate, heart-rate variability, blood-oxygen, respiratory rate, steps, distance, floors climbed, active energy, cardio fitness, sleep, and weight. It runs alongside the existing Fitbit connection and is the path forward as Google retires the older Fitbit Web API in September 2026 — connect through Google and your devices keep syncing. You register your own Google client once, and the setup guide walks through it step by step. Two things are stated plainly up front: stress and readiness scores are not offered by this interface, and Google may ask you to reconnect from time to time — the connection shows a clear prompt when it does.
|
||||
|
||||
## [1.26.1] — 2026-07-02 — Fitbit setup guide fix
|
||||
|
||||
### Fixed
|
||||
|
||||
- The Fitbit (Fitbit & Pixel) setup guide described the wrong OAuth app. HealthLog connects through the classic Fitbit Web API, so you register a **Fitbit developer app at dev.fitbit.com** and paste that Client ID/Secret — but the guide walked you through creating a **Google Cloud** OAuth client instead, which the Fitbit sign-in rejects with `unauthorized_client — Invalid client_id`. The guide now describes the correct dev.fitbit.com setup (app type, HTTPS callback, scopes), and flags that Google is retiring the classic Fitbit Web API in September 2026.
|
||||
|
||||
## [1.26.0] — 2026-07-02 — Light theme and a consistent interface
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# Fitbit integration (Fitbit & Pixel)
|
||||
|
||||
> **Experimental.** Field mapping is still being verified against live accounts,
|
||||
> so some values may be missing or change in a future update.
|
||||
|
||||
HealthLog reads Fitbit and Pixel Watch data through the **classic Fitbit Web
|
||||
API** (`api.fitbit.com`). The OAuth app is a **Fitbit developer app registered
|
||||
at [dev.fitbit.com](https://dev.fitbit.com/apps/new)** — **not** a Google Cloud
|
||||
OAuth client. Like WHOOP, credentials are brought per user: each user (or the
|
||||
operator on their behalf) registers their own Fitbit app and pastes the client
|
||||
id/secret into Settings. The internal integration key is `fitbit`.
|
||||
|
||||
> **Common mistake:** the field wants a **Fitbit** Client ID (a short numeric
|
||||
> id from dev.fitbit.com), **not** a Google Cloud client id ending in
|
||||
> `.apps.googleusercontent.com`. A Google Cloud client fails at the Fitbit
|
||||
> consent screen with `unauthorized_client — Invalid client_id`, because Fitbit
|
||||
> and Google Cloud are separate client registries.
|
||||
|
||||
> **Heads-up — September 2026 sunset.** Google is retiring the classic Fitbit
|
||||
> Web API in **September 2026** and moving to the Google Health API behind Google
|
||||
> sign-in. That replacement currently requires Google Restricted-scope brand
|
||||
> verification plus an annual third-party CASA security assessment, which does
|
||||
> not fit a self-hosted bring-your-own-credentials model, so the path beyond the
|
||||
> sunset is still being worked out. Until then the setup below is the supported
|
||||
> way to connect Fitbit.
|
||||
|
||||
## 1. Register a Fitbit developer app
|
||||
|
||||
1. Sign in at <https://dev.fitbit.com/apps/new> and register a new application.
|
||||
2. **OAuth 2.0 Application Type:**
|
||||
- **Personal** — the app only ever sees the **owner's own** Fitbit account,
|
||||
and gets intraday (per-minute) heart-rate/steps automatically. This is the
|
||||
right choice for a single-person self-host.
|
||||
- **Server** (or **Client**) — needed when the instance connects **other
|
||||
people's** accounts. Intraday/heart-rate access is then granted case by
|
||||
case via Fitbit's Intraday Data Access request form.
|
||||
3. **Callback URL:** `https://your-instance.example.com/api/fitbit/callback`
|
||||
— absolute, **HTTPS**, and matching the `redirect_uri` HealthLog sends (see
|
||||
the redirect-URI note below). One entry per environment you connect from.
|
||||
4. **Scopes:** tick the ones HealthLog requests (next section).
|
||||
5. Save. Fitbit issues a **Client ID** (short, numeric) and a **Client Secret**
|
||||
— keep the secret out of any chat log.
|
||||
|
||||
## 2. Scopes
|
||||
|
||||
HealthLog requests exactly these read scopes (space-separated on the wire):
|
||||
|
||||
```
|
||||
activity cardio_fitness heartrate oxygen_saturation profile respiratory_rate sleep weight
|
||||
```
|
||||
|
||||
The `temperature` scope is intentionally omitted — the classic skin-temperature
|
||||
reading is a baseline delta rather than an absolute value, so it has no honest
|
||||
canonical slot yet. The authorization uses the Authorization Code Grant **with
|
||||
PKCE (S256)**, which Fitbit recommends; HealthLog mints the `code_verifier`
|
||||
server-side and never exposes it to the browser.
|
||||
|
||||
## 3. Wire the credentials in Settings → Integrations
|
||||
|
||||
Credentials are stored per user, not in `.env`. Sign in as the target user, open
|
||||
**Settings → Integrations → Fitbit**, and paste the Client ID and Client Secret.
|
||||
They are encrypted AES-256-GCM at rest.
|
||||
|
||||
## 4. Connect
|
||||
|
||||
Click **Connect with Fitbit**. HealthLog redirects to the Fitbit consent screen
|
||||
(`www.fitbit.com/oauth2/authorize`), exchanges the returned code for an access +
|
||||
refresh token at `api.fitbit.com/oauth2/token`, stores them encrypted, and runs
|
||||
an initial sync. There is **no webhook** — Fitbit syncs on the safety-net cron
|
||||
pull. Classic Fitbit refresh tokens rotate (one-time use); HealthLog replaces the
|
||||
stored token on every refresh.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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"
|
||||
```
|
||||
|
||||
The Fitbit client id/secret are per-user (Settings), not env vars.
|
||||
|
||||
## Source overlap
|
||||
|
||||
If Fitbit 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.
|
||||
|
||||
## Disconnect
|
||||
|
||||
The Settings **Disconnect** button revokes the stored tokens and removes the
|
||||
connection. Previously synced measurements stay in the database.
|
||||
@@ -10,7 +10,8 @@ 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.
|
||||
integration without touching it. For the classic connection (a developer app
|
||||
registered on the Fitbit side), see [fitbit.md](./fitbit.md).
|
||||
|
||||
> **Beta.** Field mapping is still being verified against live Google Health
|
||||
> accounts, so some values may be missing or shift in a later update.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
// v1.12.0 — Google Health (Fitbit & Pixel) card. Mirrors the WHOOP card: a
|
||||
// v1.12.0 — Fitbit (Fitbit & Pixel) card. Mirrors the WHOOP card: a
|
||||
// BYO-key credentials form first, then an OAuth connect, then the
|
||||
// sync/test/disconnect action row + parked-resume banner. v1.12.1 — status
|
||||
// reads off the consolidated /api/integrations/status envelope (the per-card
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { GOOGLE_HEALTH_CORE_SCOPES } from "@/lib/google-health/client";
|
||||
|
||||
import { FITBIT_OAUTH_SCOPE } from "../client";
|
||||
|
||||
/**
|
||||
* Guard against the class of bug reported in #396: the setup docs drifting away
|
||||
* from what the code actually does. The Fitbit integration uses the CLASSIC
|
||||
* Fitbit Web API (an app from dev.fitbit.com), not a Google Cloud OAuth client —
|
||||
* a doc that says otherwise sends users to the wrong registry and fails with
|
||||
* `unauthorized_client — Invalid client_id`.
|
||||
*/
|
||||
const readDoc = (name: string): string =>
|
||||
readFileSync(join(process.cwd(), "docs", "integrations", name), "utf8");
|
||||
|
||||
describe("Fitbit setup docs stay in sync with the code", () => {
|
||||
const fitbitDoc = readDoc("fitbit.md");
|
||||
|
||||
it("documents every OAuth scope the client actually requests", () => {
|
||||
for (const scope of FITBIT_OAUTH_SCOPE.split(" ")) {
|
||||
expect(fitbitDoc).toContain(scope);
|
||||
}
|
||||
});
|
||||
|
||||
it("points at the classic Fitbit endpoints and dev.fitbit.com", () => {
|
||||
expect(fitbitDoc).toContain("dev.fitbit.com");
|
||||
expect(fitbitDoc).toContain("www.fitbit.com/oauth2/authorize");
|
||||
expect(fitbitDoc).toContain("api.fitbit.com/oauth2/token");
|
||||
expect(fitbitDoc).toContain("/api/fitbit/callback");
|
||||
});
|
||||
|
||||
it("never instructs the user to create a Google Cloud OAuth client (the #396 mistake)", () => {
|
||||
// The "do this" markers of the wrong walkthrough: the Google Cloud console
|
||||
// and the Google Health scope URLs. Mentioning `apps.googleusercontent.com`
|
||||
// as a "don't paste this" warning is allowed and intentional.
|
||||
expect(fitbitDoc).not.toContain("console.cloud.google");
|
||||
expect(fitbitDoc).not.toContain("googleapis.com/auth/googlehealth");
|
||||
});
|
||||
|
||||
it("keeps the Google Health runbook on Google endpoints (the inverse mix-up)", () => {
|
||||
// Since v1.27.0 google-health.md documents the real Google Health
|
||||
// integration — a Google Cloud OAuth client. The guard flips: this page
|
||||
// must never send the user to the classic Fitbit app registry, and the
|
||||
// scopes it lists must match what the google-health client requests.
|
||||
const googleHealthDoc = readDoc("google-health.md");
|
||||
expect(googleHealthDoc).toContain("accounts.google.com/o/oauth2/v2/auth");
|
||||
for (const scope of GOOGLE_HEALTH_CORE_SCOPES) {
|
||||
expect(googleHealthDoc).toContain(scope);
|
||||
}
|
||||
expect(googleHealthDoc).not.toContain("dev.fitbit.com");
|
||||
expect(googleHealthDoc).not.toContain("fitbit.com/oauth2/authorize");
|
||||
});
|
||||
});
|
||||
@@ -204,9 +204,16 @@ describe("GET /api/analytics — sleep-stage aggregation", () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await seedSession("sleep-reconcile-user");
|
||||
|
||||
// Contiguous overnight: 22:30 → 06:15 local (Berlin, UTC+2 in June),
|
||||
// stage ends straddling UTC midnight. WHOOP + Apple Health both report
|
||||
// it; WHOOP wins the default ladder.
|
||||
// Contiguous overnight, stage rows straddling a UTC midnight
|
||||
// (≈23:30 → 06:15 Berlin). WHOOP + Apple Health both report it;
|
||||
// WHOOP wins the default ladder. Anchor the night ~6 days back so it
|
||||
// stays inside the route's trailing 30-day window as the calendar
|
||||
// advances — fixed calendar dates seeded here expire out of the
|
||||
// window and the test starts failing a month later.
|
||||
const midnight = new Date(Date.now() - 6 * 24 * 60 * 60 * 1000);
|
||||
midnight.setUTCHours(0, 0, 0, 0);
|
||||
const at = (offsetMin: number) =>
|
||||
new Date(midnight.getTime() + offsetMin * 60_000).toISOString();
|
||||
const seed = async (
|
||||
iso: string,
|
||||
stage: "CORE" | "DEEP" | "REM",
|
||||
@@ -227,13 +234,13 @@ describe("GET /api/analytics — sleep-stage aggregation", () => {
|
||||
});
|
||||
};
|
||||
// WHOOP — the canonical source. CORE 60 + DEEP 90 + REM 120 + CORE 195.
|
||||
await seed("2026-06-03T21:30:00.000Z", "CORE", 60, "WHOOP");
|
||||
await seed("2026-06-03T23:00:00.000Z", "DEEP", 90, "WHOOP");
|
||||
await seed("2026-06-04T01:00:00.000Z", "REM", 120, "WHOOP");
|
||||
await seed("2026-06-04T04:15:00.000Z", "CORE", 195, "WHOOP");
|
||||
await seed(at(-150), "CORE", 60, "WHOOP"); // 21:30 UTC, day before
|
||||
await seed(at(-60), "DEEP", 90, "WHOOP"); // 23:00 UTC, day before
|
||||
await seed(at(60), "REM", 120, "WHOOP"); // 01:00 UTC
|
||||
await seed(at(255), "CORE", 195, "WHOOP"); // 04:15 UTC
|
||||
// Apple Health parallel rows for the SAME night — must be dropped.
|
||||
await seed("2026-06-03T21:35:00.000Z", "CORE", 55, "APPLE_HEALTH");
|
||||
await seed("2026-06-04T01:05:00.000Z", "REM", 110, "APPLE_HEALTH");
|
||||
await seed(at(-145), "CORE", 55, "APPLE_HEALTH"); // 21:35 UTC, day before
|
||||
await seed(at(65), "REM", 110, "APPLE_HEALTH"); // 01:05 UTC
|
||||
|
||||
const { GET } = await import("@/app/api/analytics/route");
|
||||
const response = await (
|
||||
|
||||
Reference in new issue
Block a user