feat(garmin): sync Garmin Connect through the Personal API

Garmin Connect now offers a Personal API: a person creates a key for
their own account and any client can buy short-lived tokens with it.
HealthLog takes that key in a new Garmin card, checks it with Garmin
before storing it sealed on the user row, and polls every hour.

- Transport: key exchange with a per-process token cache keyed on the
  key, one re-exchange on a refused token and then reauth_required, the
  429 Retry-After carried to the caller, the China host tried when the
  global one does not know the key, answers read with a byte cap, all
  egress through safeFetch to the two fixed hosts.
- Sync: daily summaries, sleep summaries, HRV, weigh-ins and the
  activity search, chunked to each resource's documented range, read one
  after another and stopped at the first rate limit; today's menstrual
  cycle summary adds logged period days the person has not logged.
- Backfill: a resumable walk through the shared admission lane that
  learns where the history starts from the daily summaries and the
  activities, waits out Garmin's rate limit and stamps its marker.
- Types: GARMIN source; BODY_BATTERY and STRESS_LEVEL for Garmin's own
  day scores, kept apart from the computed STRESS_SCORE; TRAINING_READINESS
  and ACUTE_TRAINING_LOAD for figures a bridge pushes through the ingest
  token. All four chart on the recovery page and sit on the ladders,
  units, ranges, labels and report groups like the device-native scores.
- Key expiry: learned from a token capped short of a day, else estimated
  from the validity picked at connect; the card warns a week ahead and
  asks for a new key once Garmin refuses the old one.
- Ledger, health, freshness, watchdog, backup classes, wipe plan,
  encrypted-column registry, rotation script, OpenAPI and all ten
  locales carry the new integration. The old "no Garmin connector" note
  is gone.
This commit is contained in:
Marc-André Bombeck committed 2026-11-04 10:14:10 +01:00
1 parent 67909f2d9d
commit e2ba86b12f
119 files changed
+5798 -400

No files matched your search

@@ -117,6 +117,10 @@ const GENERIC_METRIC_SCOPE_EXPECTATION: Readonly<
AVERAGE_HEART_RATE: null,
BREATHING_DISTURBANCES: null,
CARDIO_LOAD: null,
BODY_BATTERY: null,
STRESS_LEVEL: null,
TRAINING_READINESS: null,
ACUTE_TRAINING_LOAD: null,
CARDIO_RECOVERY: null,
DAY_STRAIN: null,
ENERGY_EXPENDITURE_KJ: null,
@@ -177,6 +177,10 @@ const STATUS_METRIC_SCOPE_SOURCE = {
DAY_STRAIN: null,
WORKOUT_STRAIN: null,
CARDIO_LOAD: null,
BODY_BATTERY: null,
STRESS_LEVEL: null,
TRAINING_READINESS: null,
ACUTE_TRAINING_LOAD: null,
AVERAGE_HEART_RATE: null,
MAX_HEART_RATE: null,
ENERGY_EXPENDITURE_KJ: null,
@@ -58,46 +58,17 @@ const EMPTY_COPY = {
} as const satisfies Record<RecoveryEmptyReason, string>;
/**
* v1.17.1 → rebuilt v1.18.1 (B1–B3) — body of the `/insights/recovery`
* sub-page.
*
* The page now reads exactly like every other metric page: each present
* device-native signal (WHOOP / Polar / Oura recharge, strain, load, and
* whole-day cardiac / energy figures) renders as a canonical block — a
* short explanatory line, the max / median / mean stat strip, the same
* `<HealthChartDynamic>` chart with the identical 7 / 30 / 90 / All toggle,
* and a per-chart AI assessment (`<MetricStatusCard>`). The composite
* RECOVERY_SCORE keeps its anatomy view at `/insights/scores/recovery`.
*
* B3 — the redundant "Recovery score" link block that used to lead the
* page is gone: this surface is reached from the overview already, so the
* cross-link was duplicate ("doppelt gemoppelt").
*
* Each block is data-gated (a signal with zero readings is dropped) and a
* group with no present block hides entirely, so a non-wearable account
* sees only the calm empty note. Reads the shared analytics summaries slice
* (no extra round-trip); server-authoritative throughout.
* The page's chart blocks, grouped: recharge, strain, cardio. A block only
* renders when its summary has data, so a provider-specific type costs
* nothing on an account without that provider.
*/
export function RecoverySection() {
const { t } = useTranslations();
const { data, isLoading, error, refetch } =
useInsightsAnalytics("SLEEP_DURATION");
// v1.18.1 — Rest Mode parity. The annotation rides the thick analytics
// payload's `healthScore` block (server-authoritative; iOS mirrors it). We
// read it off the same `["analytics"]` cache the insights mother page
// primes, so navigating in is a free hit and a direct URL load pays one
// thick fetch. Read-only — the score itself stays untouched.
const analyticsQuery = useAnalyticsQuery();
const restMode = ((
analyticsQuery.data?.healthScore as { restMode?: RestModeAnnotation } | null
)?.restMode ?? null) as RestModeAnnotation | null;
const summaries = data?.summaries;
function recoveryBlockGroups(
t: ReturnType<typeof useTranslations>["t"],
): BlockGroup[] {
const unitScore = t("insights.deviceScore.unitScore");
const unitBpm = t("insights.deviceScore.unitBpm");
const unitKj = t("insights.deviceScore.unitKj");
const groups: BlockGroup[] = [
return [
{
key: "recharge",
blocks: [
@@ -112,6 +83,36 @@ export function RecoverySection() {
unit: unitScore,
fractionDigits: 1,
},
{
type: "BODY_BATTERY",
chartKey: "bodyBattery",
statusMetric: "BODY_BATTERY",
title: t("measurements.typeBodyBattery"),
explainer: t("insights.recovery.block.bodyBatteryExplainer"),
icon: Battery,
color: "var(--chart-2)",
unit: unitScore,
},
{
type: "TRAINING_READINESS",
chartKey: "trainingReadiness",
statusMetric: "TRAINING_READINESS",
title: t("measurements.typeTrainingReadiness"),
explainer: t("insights.recovery.block.trainingReadinessExplainer"),
icon: HeartPulse,
color: "var(--chart-4)",
unit: unitScore,
},
{
type: "STRESS_LEVEL",
chartKey: "stressLevel",
statusMetric: "STRESS_LEVEL",
title: t("measurements.typeStressLevel"),
explainer: t("insights.recovery.block.stressLevelExplainer"),
icon: Gauge,
color: "var(--chart-3)",
unit: unitScore,
},
],
},
{
@@ -149,6 +150,16 @@ export function RecoverySection() {
color: "var(--chart-1)",
unit: unitScore,
},
{
type: "ACUTE_TRAINING_LOAD",
chartKey: "acuteTrainingLoad",
statusMetric: "ACUTE_TRAINING_LOAD",
title: t("measurements.typeAcuteTrainingLoad"),
explainer: t("insights.recovery.block.acuteTrainingLoadExplainer"),
icon: TrendingUp,
color: "var(--chart-5)",
unit: t("insights.deviceScore.unitLoad"),
},
],
},
{
@@ -187,6 +198,45 @@ export function RecoverySection() {
],
},
];
}
/**
* v1.17.1 → rebuilt v1.18.1 (B1–B3) — body of the `/insights/recovery`
* sub-page.
*
* The page now reads exactly like every other metric page: each present
* device-native signal (WHOOP / Polar / Oura recharge, strain, load, and
* whole-day cardiac / energy figures) renders as a canonical block — a
* short explanatory line, the max / median / mean stat strip, the same
* `<HealthChartDynamic>` chart with the identical 7 / 30 / 90 / All toggle,
* and a per-chart AI assessment (`<MetricStatusCard>`). The composite
* RECOVERY_SCORE keeps its anatomy view at `/insights/scores/recovery`.
*
* B3 — the redundant "Recovery score" link block that used to lead the
* page is gone: this surface is reached from the overview already, so the
* cross-link was duplicate ("doppelt gemoppelt").
*
* Each block is data-gated (a signal with zero readings is dropped) and a
* group with no present block hides entirely, so a non-wearable account
* sees only the calm empty note. Reads the shared analytics summaries slice
* (no extra round-trip); server-authoritative throughout.
*/
export function RecoverySection() {
const { t } = useTranslations();
const { data, isLoading, error, refetch } =
useInsightsAnalytics("SLEEP_DURATION");
// v1.18.1 — Rest Mode parity. The annotation rides the thick analytics
// payload's `healthScore` block (server-authoritative; iOS mirrors it). We
// read it off the same `["analytics"]` cache the insights mother page
// primes, so navigating in is a free hit and a direct URL load pays one
// thick fetch. Read-only — the score itself stays untouched.
const analyticsQuery = useAnalyticsQuery();
const restMode = ((
analyticsQuery.data?.healthScore as { restMode?: RestModeAnnotation } | null
)?.restMode ?? null) as RestModeAnnotation | null;
const summaries = data?.summaries;
const groups = recoveryBlockGroups(t);
// Whether the page has anything to show, and why not, is the server's
// answer (`recoveryEmpty`), so web and iOS read one decision.
@@ -18,6 +18,7 @@ import {
Scale,
Heart,
Activity,
Battery,
Droplets,
Droplet,
Moon,
@@ -142,6 +143,10 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
INDOOR_CO2: "measurements.typeIndoorCo2",
INDOOR_TEMPERATURE: "measurements.typeIndoorTemperature",
INDOOR_HUMIDITY: "measurements.typeIndoorHumidity",
BODY_BATTERY: "measurements.typeBodyBattery",
STRESS_LEVEL: "measurements.typeStressLevel",
TRAINING_READINESS: "measurements.typeTrainingReadiness",
ACUTE_TRAINING_LOAD: "measurements.typeAcuteTrainingLoad",
};
export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
@@ -289,6 +294,11 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
INDOOR_CO2: Wind,
INDOOR_TEMPERATURE: Thermometer,
INDOOR_HUMIDITY: Droplets,
// Garmin scores, read beside the other device-native scores.
BODY_BATTERY: Battery,
STRESS_LEVEL: Gauge,
TRAINING_READINESS: HeartPulse,
ACUTE_TRAINING_LOAD: TrendingUp,
};
// The colour map lives in its own icon-free module so a surface that only
@@ -142,4 +142,10 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
INDOOR_CO2: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
INDOOR_TEMPERATURE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
INDOOR_HUMIDITY: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
// Garmin scores take the device-native score family (chart-1),
// like ANS charge and cardio load.
BODY_BATTERY: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
STRESS_LEVEL: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
TRAINING_READINESS: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
ACUTE_TRAINING_LOAD: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
};
@@ -102,6 +102,7 @@ const PROVIDERS = [
"oura",
"strava",
"nightscout",
"garmin",
] as const;
function health(overrides: Record<string, unknown> = {}) {
@@ -176,7 +177,7 @@ describe("IntegrationsSection — one status display per card", () => {
expect(html).not.toContain("Last attempt");
// The seven other providers and Apple Health are not connected and say so
// once each.
expect(count(html, ">Not connected.<")).toBe(8);
expect(count(html, ">Not connected.<")).toBe(9);
});
it("never shows the stored provider error or a failure counter", () => {
@@ -247,17 +248,12 @@ describe("IntegrationsSection — one status display per card", () => {
});
const html = render();
expect(count(html, 'data-slot="integration-setup-guide"')).toBe(8);
expect(count(html, 'data-slot="integration-setup-guide"')).toBe(9);
for (const provider of PROVIDERS) {
expect(html).toContain(`data-testid="${provider}-setup-guide"`);
expect(html).toContain(
`href="https://docs.healthlog.dev/guides/connect-${provider}/"`,
);
}
// Garmin has no connector; its note goes straight to the section of the
// Google Health guide that explains the route, not through a redirect.
expect(html).toContain(
'href="https://docs.healthlog.dev/guides/connect-google-health/#garmin-owners"',
);
});
});
@@ -194,9 +194,9 @@ describe("<HealthRecordExportPanel> — the first run", () => {
expect(groupChip(html, "medications")).toBe("0/5");
// ALLERGIES, ILLNESS_EPISODES, VISITS, SURGICAL_HISTORY, IMMUNIZATIONS.
expect(groupChip(html, "history")).toBe("0/5");
expect(groupChip(html, "cardio")).toBe("0/15");
expect(groupChip(html, "cardio")).toBe("0/16");
expect(groupChip(html, "activity")).toBe("0/12");
expect(groupChip(html, "sleepRecovery")).toBe("0/16");
expect(groupChip(html, "sleepRecovery")).toBe("0/19");
expect(groupChip(html, "mobility")).toBe("0/11");
expect(groupChip(html, "environment")).toBe("0/7");
});
@@ -87,10 +87,13 @@ describe("structural guard: cards read health, not the retired fields", () => {
/**
* `.state` is also the name of `health.state` and of a Google Health sync
* run's progress state. Those bases are the allowed readers; any other base
* reading `.state` is a ledger state finding its way back onto a card.
* run's progress state, and of the Garmin key's expiry state (a server-
* resolved fact of its own, not a ledger state). Those bases are the
* allowed readers; any other base reading `.state` is a ledger state
* finding its way back onto a card.
*/
const STATE_READERS = /(^|\.)(health|progress|syncProgress|record)$/;
const STATE_READERS =
/(^|\.)(health|progress|syncProgress|record|keyExpiry)$/;
const files = [
...walkSourceFiles(join(SRC, CARDS), { floor: 15 })
@@ -2,7 +2,7 @@
/**
* `<ConnectionsPanel>` — the connected-services half of Settings →
* Integrations: Withings / WHOOP / Fitbit / Polar / Oura / Nightscout.
* Integrations: Withings / WHOOP / Fitbit / Polar / Oura / Garmin / Nightscout.
*
* v1.18.0 — extracted out of `integrations-section.tsx` so the section
* file can host the Connections / Channels / Sources sub-tabs without growing
@@ -20,7 +20,7 @@ import { showErrorToast } from "@/lib/errors/error-toast";
import { AppleHealthCard } from "@/components/settings/integrations/apple-health-card";
import { DawarichCard } from "@/components/settings/integrations/dawarich-card";
import { FitbitRetirementNotice } from "@/components/settings/integrations/fitbit-retirement-notice";
import { GarminInfoNote } from "@/components/settings/integrations/garmin-info-note";
import { GarminCard } from "@/components/settings/integrations/garmin-card";
import { GoogleHealthCard } from "@/components/settings/integrations/google-health-card";
import { IntegrationFallbackRow } from "@/components/settings/integrations/integration-fallback-row";
import { NightscoutCard } from "@/components/settings/integrations/nightscout-card";
@@ -203,6 +203,7 @@ const CARD_BACKED_INTEGRATIONS: ReadonlySet<IntegrationKey> = new Set([
"strava",
"nightscout",
"dawarich",
"garmin",
]);
export function ConnectionsPanel({
@@ -403,6 +404,9 @@ export function ConnectionsPanel({
callbackUrl={callbackUrls.strava}
/>
</div>
<div id="garmin" className="scroll-mt-28">
<GarminCard viewModel={pickStatus(integrationStatus, "garmin")} />
</div>
<div id="nightscout" className="scroll-mt-28">
<NightscoutCard
viewModel={pickStatus(integrationStatus, "nightscout")}
@@ -426,12 +430,6 @@ export function ConnectionsPanel({
<IntegrationFallbackRow status={entry} />
</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,412 @@
"use client";
// Garmin, through the Personal API (#1333). Like Nightscout this is a
// credential the person pastes once rather than an OAuth round-trip: they
// create a key in Garmin Connect and HealthLog buys short-lived tokens with
// it on every sync. The card reads the consolidated `/api/integrations/status`
// envelope like every sibling and renders the shared integration card. The
// one Garmin-specific fact it adds is the key's end: Garmin keys expire after
// the validity picked at creation, and the card says so ahead of time.
import { useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
BookOpen,
ExternalLink,
KeyRound,
Link2,
Loader2,
Plug,
RefreshCw,
Save,
Unlink,
Watch,
} from "lucide-react";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { PasswordInput } from "@/components/ui/password-input";
import { TagChip } from "@/components/ui/tag-chip";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import {
GARMIN_KEY_VALIDITY_DAYS,
type GarminKeyExpiry,
} from "@/lib/garmin/key-expiry";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
measurementDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import {
ActionResultLines,
useConnectionTest,
useStandardSync,
} from "./integration-actions";
import {
IntegrationCard,
type IntegrationMenuAction,
} from "./integration-card";
import { MetricFreshnessList } from "./metric-freshness-list";
import {
IntegrationCardDescription,
integrationDocsHref,
} from "./setup-guide-link";
import { failureStreakFor, type IntegrationStatusViewModel } from "./shared";
/** Where Garmin Connect creates and revokes Personal API keys. */
export const GARMIN_KEY_SETTINGS_URL =
"https://connect.garmin.com/app/settings/personalApiToken";
export function GarminCard({
viewModel,
}: {
viewModel?: IntegrationStatusViewModel;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const queryClient = useQueryClient();
const status = viewModel;
const connected = status?.connected ?? false;
const sync = useStandardSync({
provider: "garmin",
i18nPrefix: "settings.garmin",
acceptsFullSync: false,
});
const test = useConnectionTest("/api/garmin/test");
const [editing, setEditing] = useState(false);
const formRef = useRef<HTMLFormElement | null>(null);
const disconnect = useMutation({
mutationFn: async () => {
await apiPost("/api/garmin/disconnect");
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
},
});
// An expired or revoked key is resolved by pasting a new one: the button
// opens the form and puts the cursor in the key field.
function openForm() {
setEditing(true);
requestAnimationFrame(() => {
formRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
formRef.current
?.querySelector<HTMLInputElement>("#garmin-api-key")
?.focus();
});
}
const form = <GarminForm formRef={formRef} connected={connected} />;
if (!connected) {
return (
<IntegrationCard
provider="garmin"
providerName={t("settings.garmin")}
icon={Watch}
titleAccessory={<TagChip>{t("settings.garminTag")}</TagChip>}
description={
<IntegrationCardDescription
i18nPrefix="settings.garmin"
provider="garmin"
/>
}
loading={viewModel === undefined}
connected={false}
health={null}
setup={form}
/>
);
}
const keyExpiry = status?.keyExpiry ?? null;
const expiryNote = garminKeyExpiryNote(keyExpiry, t, fmt.date);
const menu: IntegrationMenuAction[] = [
{
id: "sync",
label: t("settings.garminSync"),
icon: RefreshCw,
disabled: sync.syncing,
onSelect: () => sync.run(false),
},
{
id: "test",
label: t("settings.testConnection.test"),
icon: Plug,
disabled: test.testing,
onSelect: test.run,
},
{
id: "view-data",
label: t("settings.garminViewData"),
icon: ArrowRight,
href: "/insights/recovery",
},
{
id: "credentials",
label: t("settings.garminReplaceKey"),
icon: KeyRound,
onSelect: () => setEditing((open) => !open),
},
{
id: "setup-guide",
label: t("settings.integrationSetupGuide"),
icon: BookOpen,
href: integrationDocsHref("garmin"),
external: true,
},
{
id: "disconnect",
label: t("settings.garminDisconnect"),
icon: Unlink,
destructive: true,
onSelect: () => disconnect.mutate(),
confirm: {
title: t("settings.garminDisconnectTitle"),
body: t("settings.garminDisconnectDescription"),
confirmLabel: t("settings.garminDisconnect"),
},
},
];
const streak = failureStreakFor(status);
return (
<IntegrationCard
provider="garmin"
providerName={t("settings.garmin")}
icon={Watch}
titleAccessory={<TagChip>{t("settings.garminTag")}</TagChip>}
connected
health={status?.health ?? null}
historyLoading={status?.backfillCompleted === false}
primary={{ onReconnect: openForm, onResume: openForm }}
menu={menu}
diagnosticsExtra={
streak
? [
t("settings.integrationCard.details.failureStreak", {
count: streak.count,
threshold: streak.threshold,
}),
]
: undefined
}
details={
<>
{keyExpiry?.state === "ok" && keyExpiry.expiresAt ? (
<p
className="text-muted-foreground text-xs"
data-testid="garmin-key-valid-until"
>
{t("settings.garminKeyValidUntil", {
date: fmt.date(keyExpiry.expiresAt),
})}
</p>
) : null}
{status?.metricFreshness?.length ? (
<MetricFreshnessList entries={status.metricFreshness} />
) : null}
</>
}
>
{expiryNote ? (
// The one fact the generic sentence cannot carry: the key has an end
// date the person chose, and only a new key moves it. Said in the
// body, not as a banner, like Nightscout's operator note.
<p
className="text-sm"
data-testid="garmin-key-expiry"
data-state={keyExpiry?.state}
>
{expiryNote}
</p>
) : null}
<ActionResultLines provider="garmin" sync={sync} test={test} />
{editing ? form : null}
</IntegrationCard>
);
}
/**
* The body sentence for a key that has expired, is about to, or whose end is
* unknown; null while it is comfortably valid.
*/
export function garminKeyExpiryNote(
keyExpiry: GarminKeyExpiry | null | undefined,
t: ReturnType<typeof useTranslations>["t"],
formatDate: (iso: string) => string,
): string | null {
if (!keyExpiry) return null;
const { expiresAt, daysLeft } = keyExpiry;
switch (keyExpiry.state) {
case "expired":
return t("settings.garminKeyExpired", {
date: expiresAt ? formatDate(expiresAt) : "",
});
case "expiring":
return daysLeft === 0
? t("settings.garminKeyExpiresToday")
: t("settings.garminKeyExpiring", {
date: expiresAt ? formatDate(expiresAt) : "",
});
case "unknown":
return t("settings.garminKeyExpiryUnknown");
default:
return null;
}
}
function GarminForm({
formRef,
connected,
}: {
formRef: React.RefObject<HTMLFormElement | null>;
connected: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [apiKey, setApiKey] = useState("");
const [validity, setValidity] = useState("");
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<{
kind: "success" | "error";
text: string;
} | null>(null);
async function handleConnect(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setMsg(null);
try {
const res = await apiFetchRaw("/api/garmin/connect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
apiKey: apiKey.trim(),
validityDays: validity ? Number(validity) : null,
}),
});
if (res.ok) {
setMsg({ kind: "success", text: t("settings.garminConnected") });
setApiKey("");
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
let text = t("settings.garminConnectFailed");
try {
const json = await res.json();
if (json.error) text = json.error;
} catch {
// keep the generic message
}
setMsg({ kind: "error", text });
}
} catch {
setMsg({ kind: "error", text: t("common.networkError") });
}
setSaving(false);
}
return (
<form
ref={formRef}
onSubmit={handleConnect}
className="space-y-3"
data-testid="garmin-form"
>
<p className="text-muted-foreground text-xs">
{t("settings.garminKeyHowTo")}{" "}
<a
href={GARMIN_KEY_SETTINGS_URL}
target="_blank"
rel="noopener noreferrer"
data-testid="garmin-key-settings-link"
className="hover:text-foreground inline-flex items-center gap-1 underline underline-offset-2"
>
{t("settings.garminKeySettingsLink")}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
</p>
<div className="space-y-1.5">
<Label htmlFor="garmin-api-key">{t("settings.garminApiKey")}</Label>
<PasswordInput
id="garmin-api-key"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={
connected
? t("settings.garminApiKeySavedPlaceholder")
: t("settings.garminApiKeyPlaceholder")
}
maxLength={1024}
autoComplete="off"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="next"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="garmin-key-validity">
{t("settings.garminKeyValidity")}
</Label>
<NativeSelect
id="garmin-key-validity"
value={validity}
onChange={(e) => setValidity(e.target.value)}
>
<option value="">{t("settings.garminKeyValidityUnknown")}</option>
{GARMIN_KEY_VALIDITY_DAYS.map((days) => (
<option key={days} value={String(days)}>
{t("settings.garminKeyValidityDays", { count: days })}
</option>
))}
</NativeSelect>
<p className="text-muted-foreground text-xs">
{t("settings.garminKeyValidityHelp")}
</p>
</div>
{msg && (
<p
role={msg.kind === "error" ? "alert" : "status"}
className={`text-sm ${msg.kind === "success" ? "text-success" : "text-destructive"}`}
>
{msg.text}
</p>
)}
<SettingsCardActions>
<Button
type="submit"
size="sm"
variant={connected ? "outline" : "default"}
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
disabled={saving || !apiKey.trim()}
data-slot={connected ? undefined : "integration-primary-action"}
data-action={connected ? undefined : "connect"}
data-testid="garmin-connect"
>
{saving ? (
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" />
) : connected ? (
<Save className="size-3.5" aria-hidden="true" />
) : (
<Link2 className="size-3.5" aria-hidden="true" />
)}
{connected ? t("settings.garminUpdate") : t("settings.garminConnect")}
</Button>
</SettingsCardActions>
</form>
);
}
@@ -1,43 +0,0 @@
"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 { GARMIN_DOCS_HREF } 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={GARMIN_DOCS_HREF}
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>
);
}
@@ -40,6 +40,7 @@ export const INTEGRATION_DISPLAY_NAMES: Record<IntegrationKey, string> = {
"google-health": "Google Health",
strava: "Strava",
dawarich: "Dawarich",
garmin: "Garmin",
};
/**
@@ -25,9 +25,6 @@ import { useTranslations } from "@/lib/i18n/context";
*/
export const DOCS_GUIDES_BASE = "https://docs.healthlog.dev/guides";
/** Garmin has no connector; the Google Health guide explains the route. */
export const GARMIN_DOCS_HREF = `${DOCS_GUIDES_BASE}/connect-google-health/#garmin-owners`;
export type IntegrationDocsProvider =
| "whoop"
| "withings"
@@ -37,7 +34,8 @@ export type IntegrationDocsProvider =
| "nightscout"
| "google-health"
| "strava"
| "dawarich";
| "dawarich"
| "garmin";
export function integrationDocsHref(provider: IntegrationDocsProvider): string {
return `${DOCS_GUIDES_BASE}/connect-${provider}/`;
@@ -15,6 +15,7 @@ import type { ConnectionHealth } from "@/lib/integrations/connection-health";
import type { MetricFreshnessEntry } from "@/lib/integrations/sync-verdict";
import { queryKeys } from "@/lib/query-keys";
import type { PublishedFitbitRetirement } from "@/lib/fitbit/retirement";
import type { GarminKeyExpiry } from "@/lib/garmin/key-expiry";
export type IntegrationKey =
| "withings"
@@ -30,7 +31,9 @@ export type IntegrationKey =
// status and inherits the shared verdict.
| "nightscout"
// v1.43 — Dawarich movement context; publishes `health` only.
| "dawarich";
| "dawarich"
// Garmin Personal API; a pasted key, like Nightscout.
| "garmin";
/**
* One envelope entry as the cards read it. Since v1.42.4 the cards render
* `health` alone; the older health-shaped fields (ledger `state`, `lastError`,
@@ -54,7 +57,7 @@ export interface IntegrationStatusViewModel {
failureThreshold?: number;
configured?: boolean;
connected?: boolean;
/** WHOOP / Fitbit / Google Health: older history still loading. */
/** WHOOP / Fitbit / Google Health / Garmin: older history still loading. */
backfillCompleted?: boolean | null;
/**
* Google Health: data types whose history backfill ended before the start
@@ -70,6 +73,8 @@ export interface IntegrationStatusViewModel {
hasOwnCredentials?: boolean;
/** Nightscout: a stored token. */
hasToken?: boolean;
/** Garmin: when the stored key stops working, resolved on the server. */
keyExpiry?: GarminKeyExpiry | null;
/** Per-metric last-seen timestamps with the server-computed stale flag. */
metricFreshness?: MetricFreshnessEntry[];
/** Fitbit: the end of the legacy Web API, resolved on the server. */