mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
feat(strava): add per-user BYO-OAuth workout source + Garmin indirect-path docs
Add Strava as a per-user BYO-OAuth workout source, mirroring the shipped WHOOP/Oura/Polar pattern. Strava caps every new app at single-player mode, so each self-hoster registers their own app and pastes the client id/secret into Settings (falling back to a shared env app). Strava is a workout source only — it exposes no daily metrics and no training-depth analytics are derived. - Schema (migration 0233): per-user encrypted client id/secret + access/refresh tokens, plaintext athlete id, incremental cursor, and backfill marker on `User`; `STRAVA` added to `MeasurementSource`. - Token model follows Oura's reactive-refresh-on-401 with a compare-and-set on the stored refresh ciphertext, because Strava rotates its refresh token on every refresh. - Client over safeFetch: authorization-code + refresh, activity list + budget- aware per-activity detail (for calories/HR), honouring the 200/15min cap; each activity maps to one `Workout` row. Activity name/description stay in metadata as data, never surfaced or interpolated. - Dedup reuses the existing source-agnostic two-layer picker: `STRAVA` slots into the workout ladder below the device-native wearables and above MANUAL/IMPORT, so an Apple-Watch + Strava twin collapses to one canonical row. - Poll-cohort hourly sync (:17) + self-converging boot backfill, wired into the integration-sync registrar. Webhook deferred. - OAuth routes (connect/callback/credentials/disconnect/status/test) with signed state, cross-account re-auth rejection, and a surgical `www.strava.com` connect-src gated to the Strava surfaces. - Settings card + consolidated status envelope entry; env + compose whitelist; i18n across all six locales. - Garmin: no direct connector is possible (business-partner-only). Add docs/integrations/garmin.md documenting the Apple Health / Google Health Connect path and Garmin's withheld proprietary scores, cross-linked from both platform docs, plus a non-connector info note in the Connections panel. Regenerate docs/api/openapi.yaml for the new `STRAVA` MeasurementSource value.
This commit is contained in:
1 parent
89634e20ee
commit
75eb0637e6
49 files changed
+2683
-15
No files matched your search
@@ -131,10 +131,11 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
});
|
||||
|
||||
const html = render();
|
||||
// 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);
|
||||
// Exactly one pill per card → 8 pills total (Withings, WHOOP, Fitbit,
|
||||
// Google Health, Polar, Oura, Strava, Nightscout). The moodLog integration
|
||||
// was removed; Polar + Oura (F4) were added in v1.17.0; Google Health in
|
||||
// v1.27.0; Strava in v1.28.x. (The Garmin info note carries no pill.)
|
||||
expect(count(html, 'data-testid="integration-status-pill"')).toBe(8);
|
||||
// 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
|
||||
@@ -326,8 +327,9 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
});
|
||||
|
||||
const html = render();
|
||||
// One setup-guide link per card → seven providers.
|
||||
expect(count(html, 'data-slot="integration-setup-guide"')).toBe(7);
|
||||
// One setup-guide link per card → eight providers (the Garmin info note is
|
||||
// not an OAuth card and carries its own docs link, not a setup-guide).
|
||||
expect(count(html, 'data-slot="integration-setup-guide"')).toBe(8);
|
||||
for (const provider of [
|
||||
"withings",
|
||||
"whoop",
|
||||
@@ -335,6 +337,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
"google-health",
|
||||
"polar",
|
||||
"oura",
|
||||
"strava",
|
||||
"nightscout",
|
||||
]) {
|
||||
expect(html).toContain(`data-testid="${provider}-setup-guide"`);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { parseOAuthOutcome, oauthReasonKey } from "../integrations-section";
|
||||
|
||||
describe("parseOAuthOutcome", () => {
|
||||
it("reads a connected outcome for every OAuth provider", () => {
|
||||
for (const p of ["polar", "oura", "whoop", "fitbit"] as const) {
|
||||
for (const p of ["polar", "oura", "whoop", "fitbit", "strava"] as const) {
|
||||
expect(parseOAuthOutcome(`?${p}=connected`)).toEqual({
|
||||
provider: p,
|
||||
kind: "connected",
|
||||
@@ -25,6 +25,11 @@ describe("parseOAuthOutcome", () => {
|
||||
kind: "error",
|
||||
reason: "token",
|
||||
});
|
||||
expect(parseOAuthOutcome("?strava=error&reason=cross_user")).toEqual({
|
||||
provider: "strava",
|
||||
kind: "error",
|
||||
reason: "cross_user",
|
||||
});
|
||||
expect(parseOAuthOutcome("?fitbit=error&reason=expired")).toEqual({
|
||||
provider: "fitbit",
|
||||
kind: "error",
|
||||
|
||||
@@ -17,11 +17,13 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { FitbitCard } from "@/components/settings/integrations/fitbit-card";
|
||||
import { GarminInfoNote } from "@/components/settings/integrations/garmin-info-note";
|
||||
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";
|
||||
import { PolarCard } from "@/components/settings/integrations/polar-card";
|
||||
import { StravaCard } from "@/components/settings/integrations/strava-card";
|
||||
import {
|
||||
pickStatus,
|
||||
useIntegrationStatuses,
|
||||
@@ -71,6 +73,8 @@ const OAUTH_OUTCOME_PROVIDERS = [
|
||||
"whoop",
|
||||
"fitbit",
|
||||
"googleHealth",
|
||||
// v1.28.x — Strava callback redirects with `?strava=connected|error&reason=…`.
|
||||
"strava",
|
||||
] as const;
|
||||
type OAuthOutcomeProvider = (typeof OAUTH_OUTCOME_PROVIDERS)[number];
|
||||
|
||||
@@ -83,6 +87,7 @@ const OAUTH_OUTCOME_KEYS: Record<
|
||||
whoop: queryKeys.whoop,
|
||||
fitbit: queryKeys.fitbit,
|
||||
googleHealth: queryKeys.googleHealth,
|
||||
strava: queryKeys.strava,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -259,6 +264,10 @@ export function ConnectionsPanel() {
|
||||
// no longer fire their own /api/<provider>/status round-trip.
|
||||
const polarViewModel = toOAuthStatus(pickStatus(integrationStatus, "polar"));
|
||||
const ouraViewModel = toOAuthStatus(pickStatus(integrationStatus, "oura"));
|
||||
// v1.28.x — Strava reads off the same consolidated envelope.
|
||||
const stravaViewModel = toOAuthStatus(
|
||||
pickStatus(integrationStatus, "strava"),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-slot="connections-panel">
|
||||
@@ -282,9 +291,18 @@ export function ConnectionsPanel() {
|
||||
<div id="oura" className="scroll-mt-28">
|
||||
<OuraCard enabled={isAuthenticated} viewModel={ouraViewModel} />
|
||||
</div>
|
||||
<div id="strava" className="scroll-mt-28">
|
||||
<StravaCard enabled={isAuthenticated} viewModel={stravaViewModel} />
|
||||
</div>
|
||||
<div id="nightscout" className="scroll-mt-28">
|
||||
<NightscoutCard enabled={isAuthenticated} />
|
||||
</div>
|
||||
{/* Garmin: no direct connector (business-partner-only). A quiet
|
||||
non-connector note pointing to the Apple Health / Health Connect
|
||||
path — not an OAuthProviderCard (nothing to connect). */}
|
||||
<div id="garmin" className="scroll-mt-28">
|
||||
<GarminInfoNote />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
// v1.28.x — Garmin is NOT a direct connector: Garmin's developer program is
|
||||
// business-partner-only (approval-gated, needs public callbacks), so the
|
||||
// self-hosted BYO model cannot register a paste-your-key Garmin app. The honest
|
||||
// path is Apple Health (iOS) or Google Health Connect (Android), which already
|
||||
// works with zero extra code. This quiet, non-connector info note pre-empts the
|
||||
// recurring "where's Garmin?" question and points to the runbook.
|
||||
|
||||
import { ExternalLink, Watch } from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { INTEGRATION_DOCS_BASE } from "@/components/settings/integrations/setup-guide-link";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export function GarminInfoNote() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<SettingsCard data-testid="garmin-info">
|
||||
<SettingsCardHeader
|
||||
icon={Watch}
|
||||
title={t("settings.garminInfo.title")}
|
||||
description={
|
||||
<p className="text-muted-foreground">
|
||||
{t("settings.garminInfo.body")}{" "}
|
||||
<a
|
||||
href={`${INTEGRATION_DOCS_BASE}/garmin`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="garmin-docs-link"
|
||||
data-slot="garmin-docs-link"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 underline underline-offset-2"
|
||||
>
|
||||
{t("settings.garminInfo.docsLink")}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -89,7 +89,7 @@ function pillStateFor(
|
||||
|
||||
export interface OAuthProviderCardProps {
|
||||
/** Lower-case provider key, used for routes + query keys + testids. */
|
||||
provider: "polar" | "oura";
|
||||
provider: "polar" | "oura" | "strava";
|
||||
/** The query-key array shared by the status read + the invalidations. */
|
||||
statusQueryKey: readonly unknown[];
|
||||
/** i18n key prefix (e.g. `settings.polar`). */
|
||||
|
||||
@@ -30,7 +30,9 @@ export type IntegrationDocsProvider =
|
||||
| "oura"
|
||||
| "nightscout"
|
||||
// v1.27.0 — Google Health runbook (docs.healthlog.dev/integrations/google-health).
|
||||
| "google-health";
|
||||
| "google-health"
|
||||
// v1.28.x — Strava runbook (docs.healthlog.dev/integrations/strava).
|
||||
| "strava";
|
||||
|
||||
export function integrationDocsHref(provider: IntegrationDocsProvider): string {
|
||||
return `${INTEGRATION_DOCS_BASE}/${provider}`;
|
||||
|
||||
@@ -26,7 +26,9 @@ export type IntegrationKey =
|
||||
| "polar"
|
||||
| "oura"
|
||||
// v1.27.0 — Google Health (Fitbit + Pixel Watch + Fitbit Air).
|
||||
| "google-health";
|
||||
| "google-health"
|
||||
// v1.28.x — Strava OAuth workout source.
|
||||
| "strava";
|
||||
export type IntegrationState =
|
||||
"connected" | "error_transient" | "error_reauth" | "disconnected" | "parked";
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
// v1.28.x — Strava integration card. Thin wrapper over the shared OAuth card
|
||||
// (per-user BYO OAuth credentials, DB-first then env). Strava is a workout
|
||||
// source: connecting it syncs your activities into the workout surface, where
|
||||
// the cross-source picker collapses any twin already captured by a wearable.
|
||||
|
||||
import { Activity } from "lucide-react";
|
||||
|
||||
import {
|
||||
OAuthProviderCard,
|
||||
type OAuthProviderStatus,
|
||||
} from "@/components/settings/integrations/oauth-provider-card";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
export function StravaCard({
|
||||
enabled = true,
|
||||
viewModel,
|
||||
}: {
|
||||
enabled?: boolean;
|
||||
viewModel?: OAuthProviderStatus;
|
||||
}) {
|
||||
return (
|
||||
<OAuthProviderCard
|
||||
provider="strava"
|
||||
statusQueryKey={queryKeys.strava()}
|
||||
i18nPrefix="settings.strava"
|
||||
icon={Activity}
|
||||
dataHref="/insights/workouts"
|
||||
credentials
|
||||
enabled={enabled}
|
||||
viewModel={viewModel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user