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:
Marc-André Bombeck committed 2026-07-09 21:25:25 +02:00
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}
/>
);
}