mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +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.
|
||||
|
||||
Reference in new issue
Block a user