fix(onboarding): offer Garmin as a source

The setup question about where readings come from listed every other
connection on Settings and not Garmin, so the flow could neither record it
nor end on connecting it. Garmin is now a Q4 source and a browser
connectable one: the key is pasted on its card, so nothing can be missing
up front.

A guard now pins that every connection the health read covers is either an
onboarding source or named with the reason it is not.

Refs #1333
This commit is contained in:
Marc-André Bombeck committed 2026-11-04 22:03:08 +01:00
1 parent 148af765c9
commit afdb8163ce
16 files changed
+68 -2

No files matched your search

+3
View File
@@ -24,6 +24,9 @@
cycles like a period logged by hand, a finished Garmin period also marks
its last day, and periods imported earlier are repaired once on the next
start. A day you logged yourself is never changed. (#1333)
- **Garmin in the setup questions.** The onboarding question about where
your readings come from now offers Garmin, and can end on connecting it.
(#1333)
- **Typing into the smoking history.** "When you stopped" only took text
that was already a whole YYYY-MM month, so every keystroke was dropped
and only a paste got in. The start year clamped each keystroke into its
+3 -1
View File
@@ -36694,7 +36694,7 @@ components:
type: string
const: done
sources:
maxItems: 10
maxItems: 11
type: array
items:
type: string
@@ -36705,6 +36705,7 @@ components:
- oura
- whoop
- polar
- garmin
- fitbit
- strava
- nightscout
@@ -83103,6 +83104,7 @@ components:
- oura
- whoop
- polar
- garmin
- fitbit
- strava
- nightscout
+1
View File
@@ -5277,6 +5277,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
+1
View File
@@ -5277,6 +5277,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
+1
View File
@@ -5277,6 +5277,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
+1
View File
@@ -5277,6 +5277,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
+1
View File
@@ -5277,6 +5277,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
+1
View File
@@ -5277,6 +5277,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
+1
View File
@@ -5277,6 +5277,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
+1
View File
@@ -5277,6 +5277,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
+1
View File
@@ -5277,6 +5277,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
+1
View File
@@ -5277,6 +5277,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
@@ -8,6 +8,7 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { CONNECTION_KEYS } from "@/lib/integrations/connection-health";
import { ONBOARDING_AREA_KEYS } from "@/lib/modules/registry";
import { NAV_DESTINATIONS } from "@/components/layout/nav-model";
@@ -18,8 +19,22 @@ import {
deliveringSources,
SOURCE_INTEGRATION,
} from "../first-result-config";
import { ONBOARDING_SOURCE_KEYS } from "../needs";
import { BROWSER_CONNECTABLE_SOURCES } from "../wizard-steps";
/**
* Connections the setup flow deliberately does not ask about, each with its
* reason. Every other connection must be a Q4 source; a provider shipped on
* Settings and missing here was absent from the flow without anybody deciding
* it.
*/
const NOT_AN_ONBOARDING_SOURCE: Record<string, string> = {
"google-health":
"the successor transport behind the Fitbit answer, which still links the classic Fitbit card",
dawarich:
"movement context for the environment module, not a source of readings",
};
describe("first-result targets", () => {
it("covers every area with exactly one of a form or a page", () => {
for (const area of ONBOARDING_AREA_KEYS) {
@@ -36,6 +51,33 @@ describe("first-result targets", () => {
}
});
it("asks about every connection Settings offers, or says why not", () => {
const sources = new Set<string>(ONBOARDING_SOURCE_KEYS);
const missing = CONNECTION_KEYS.filter(
(key) => !sources.has(key) && !(key in NOT_AN_ONBOARDING_SOURCE),
);
expect(missing).toEqual([]);
for (const key of Object.keys(NOT_AN_ONBOARDING_SOURCE)) {
expect(CONNECTION_KEYS as readonly string[]).toContain(key);
}
});
it("offers Garmin as a source the browser connects with a pasted key", () => {
expect(BROWSER_CONNECTABLE_SOURCES).toContain("garmin");
expect(SOURCE_INTEGRATION.garmin).toEqual({
anchor: "garmin",
statusKey: "garmin",
credentials: "self-hosted",
});
const view = connectSourceView("garmin", {
connected: false,
configured: false,
syncHealth: { verdict: "disconnected", since: null },
lastSuccessAt: null,
});
expect(view?.slot).toBe("connect");
});
it("names an integration for every source the browser can connect", () => {
for (const source of BROWSER_CONNECTABLE_SOURCES) {
expect(SOURCE_INTEGRATION[source].anchor).toBeTruthy();
+8 -1
View File
@@ -61,7 +61,8 @@ export const AREA_PAGE_HREF: Readonly<
* a dead end.
* - `shared` — an operator env app is the fallback (`available` says whether
* any usable credentials resolve, own or shared).
* - `self-hosted` — a URL the person pastes; nothing can be missing up front.
* - `self-hosted` — a URL or a key the person pastes; nothing can be missing
* up front.
*/
export type SourceCredentialModel = "byo" | "shared" | "self-hosted";
@@ -80,6 +81,12 @@ export const SOURCE_INTEGRATION: Readonly<
oura: { anchor: "oura", statusKey: "oura", credentials: "shared" },
whoop: { anchor: "whoop", statusKey: "whoop", credentials: "byo" },
polar: { anchor: "polar", statusKey: "polar", credentials: "shared" },
// The person's own Personal API key, pasted on the card.
garmin: {
anchor: "garmin",
statusKey: "garmin",
credentials: "self-hosted",
},
fitbit: { anchor: "fitbit", statusKey: "fitbit", credentials: "byo" },
strava: { anchor: "strava", statusKey: "strava", credentials: "shared" },
nightscout: {
+1
View File
@@ -99,6 +99,7 @@ export const ONBOARDING_SOURCE_KEYS = [
"oura",
"whoop",
"polar",
"garmin",
"fitbit",
"strava",
"nightscout",
+1
View File
@@ -34,6 +34,7 @@ export const BROWSER_CONNECTABLE_SOURCES = [
"oura",
"whoop",
"polar",
"garmin",
"fitbit",
"strava",
"nightscout",