mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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. */
|
||||
|
||||
Reference in new issue
Block a user