mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
fix(onboarding): the connect step reads the sync verdict
The step decided "connected" from the ledger's `state` and had one piece of copy for every outcome: "the first sync is in progress". That sentence was false for a connection delivering for months, and worse for one the person has to repair — the screen said everything was fine while the grant was gone. `connectSourceView` resolves the envelope entry into one of four tiles and says whether the flow may record its result. Only `pending_first_sync` and `fresh` may; `stale` is acknowledged without being claimed as new; `stalled`, `failing`, `reauth_required` and `parked` name the state and link at the page that can change it. An unresolved envelope paints no tile at all. A source the instance cannot connect — a BYO provider with no client pair, a shared-app provider with no app — now points at the credentials instead of offering a "Connect" button onto a card that has none.
This commit is contained in:
1 parent
0bdbf009f6
commit
da58d16642
11 files changed
+883
-45
No files matched your search
@@ -3,6 +3,9 @@
|
||||
* on a surface that exists, so the first-result screen can never be handed
|
||||
* a task it cannot render.
|
||||
*/
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ONBOARDING_AREA_KEYS } from "@/lib/modules/registry";
|
||||
@@ -11,6 +14,7 @@ import { NAV_DESTINATIONS } from "@/components/layout/nav-model";
|
||||
import {
|
||||
AREA_PAGE_HREF,
|
||||
AREA_READING_TARGETS,
|
||||
connectSourceView,
|
||||
SOURCE_INTEGRATION,
|
||||
} from "../first-result-config";
|
||||
import { BROWSER_CONNECTABLE_SOURCES } from "../wizard-steps";
|
||||
@@ -38,3 +42,180 @@ describe("first-result targets", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.38.19 (wave B / C1, I2, I3) — what the connect step is allowed to say.
|
||||
*
|
||||
* The screen used to decide "connected" from the ledger's `state` field, whose
|
||||
* default meant the opposite of its name, and had one piece of copy for every
|
||||
* outcome. The decision is a pure function over the envelope entry now, so the
|
||||
* eight verdicts can be enumerated here rather than inferred from markup.
|
||||
*/
|
||||
describe("connectSourceView", () => {
|
||||
const cases = [
|
||||
["disconnected", "connect", false],
|
||||
["pending_first_sync", "result", true],
|
||||
["fresh", "result", true],
|
||||
["stale", "result", false],
|
||||
["stalled", "attention", false],
|
||||
["failing", "attention", false],
|
||||
["reauth_required", "attention", false],
|
||||
["parked", "attention", false],
|
||||
] as const;
|
||||
|
||||
it.each(cases)(
|
||||
"verdict %s renders the %s tile and settles=%s",
|
||||
(verdict, slot, settled) => {
|
||||
const view = connectSourceView("whoop", {
|
||||
connected: verdict !== "disconnected",
|
||||
configured: true,
|
||||
syncHealth: { verdict, since: "2026-09-01T00:00:00.000Z" },
|
||||
lastSuccessAt: "2026-09-01T00:00:00.000Z",
|
||||
});
|
||||
expect(view?.slot).toBe(slot);
|
||||
expect(view?.settled).toBe(settled);
|
||||
expect(view?.verdict).toBe(verdict);
|
||||
},
|
||||
);
|
||||
|
||||
it("says nothing at all until the envelope has resolved", () => {
|
||||
expect(connectSourceView("whoop", undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not read a fresh account's empty ledger as a connection", () => {
|
||||
// What a brand-new account publishes: no row anywhere, so `state` is the
|
||||
// synthetic `unknown` and the verdict is `disconnected`.
|
||||
const view = connectSourceView("whoop", {
|
||||
connected: false,
|
||||
configured: true,
|
||||
state: "unknown",
|
||||
syncHealth: { verdict: "disconnected", since: null },
|
||||
lastSuccessAt: null,
|
||||
});
|
||||
expect(view?.slot).toBe("connect");
|
||||
expect(view?.settled).toBe(false);
|
||||
});
|
||||
|
||||
it("points a BYO provider with no credentials at the credentials", () => {
|
||||
const view = connectSourceView("whoop", {
|
||||
connected: false,
|
||||
configured: false,
|
||||
syncHealth: { verdict: "disconnected", since: null },
|
||||
lastSuccessAt: null,
|
||||
});
|
||||
expect(view?.slot).toBe("credentials");
|
||||
});
|
||||
|
||||
it("points a shared-app provider with no usable app at the credentials", () => {
|
||||
const view = connectSourceView("oura", {
|
||||
connected: false,
|
||||
configured: false,
|
||||
available: false,
|
||||
syncHealth: { verdict: "disconnected", since: null },
|
||||
lastSuccessAt: null,
|
||||
});
|
||||
expect(view?.slot).toBe("credentials");
|
||||
});
|
||||
|
||||
it("offers the connect card when the instance's shared app resolves", () => {
|
||||
const view = connectSourceView("oura", {
|
||||
connected: false,
|
||||
configured: false,
|
||||
available: true,
|
||||
syncHealth: { verdict: "disconnected", since: null },
|
||||
lastSuccessAt: null,
|
||||
});
|
||||
expect(view?.slot).toBe("connect");
|
||||
});
|
||||
|
||||
it("never withholds the connect card from a self-hosted URL source", () => {
|
||||
const view = connectSourceView("nightscout", {
|
||||
connected: false,
|
||||
configured: false,
|
||||
syncHealth: { verdict: "disconnected", since: null },
|
||||
lastSuccessAt: null,
|
||||
});
|
||||
expect(view?.slot).toBe("connect");
|
||||
});
|
||||
|
||||
it("dates a stale tile from the last value and a failing one from the streak", () => {
|
||||
expect(
|
||||
connectSourceView("whoop", {
|
||||
connected: true,
|
||||
configured: true,
|
||||
syncHealth: { verdict: "stale", since: "2026-08-01T00:00:00.000Z" },
|
||||
lastSuccessAt: "2026-08-20T00:00:00.000Z",
|
||||
})?.when,
|
||||
).toBe("2026-08-20T00:00:00.000Z");
|
||||
expect(
|
||||
connectSourceView("whoop", {
|
||||
connected: true,
|
||||
configured: true,
|
||||
syncHealth: { verdict: "failing", since: "2026-08-01T00:00:00.000Z" },
|
||||
lastSuccessAt: null,
|
||||
})?.when,
|
||||
).toBe("2026-08-01T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The connect step's copy is chosen by a key built at runtime from the
|
||||
* verdict, which the repo's three i18n guards cannot see into: a template
|
||||
* `ns.${x}` counts as covering its whole subtree, so a MEMBER missing from
|
||||
* every bundle is invisible to them. Enumerate the verdicts here instead —
|
||||
* the eight the server can resolve, in all seven locales.
|
||||
*/
|
||||
describe("connect-source state copy", () => {
|
||||
const VERDICTS = [
|
||||
"fresh",
|
||||
"stale",
|
||||
"stalled",
|
||||
"failing",
|
||||
"reauth_required",
|
||||
"parked",
|
||||
"pending_first_sync",
|
||||
] as const;
|
||||
|
||||
const LOCALES = readdirSync(join(__dirname, "../../../../messages"))
|
||||
.filter((file) => file.endsWith(".json"))
|
||||
.map((file) => ({
|
||||
locale: file.replace(/\.json$/, ""),
|
||||
copy: JSON.parse(
|
||||
readFileSync(join(__dirname, "../../../../messages", file), "utf8"),
|
||||
) as Record<string, never>,
|
||||
}));
|
||||
|
||||
it("covers all seven locales", () => {
|
||||
expect(LOCALES.length).toBe(7);
|
||||
});
|
||||
|
||||
it.each(LOCALES)(
|
||||
"names every verdict in $locale, with no English fallback",
|
||||
({ locale, copy }) => {
|
||||
const block = (
|
||||
copy as unknown as {
|
||||
onboarding: {
|
||||
flow: {
|
||||
"first-result": {
|
||||
"connect-source": Record<string, Record<string, unknown>>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
).onboarding.flow["first-result"]["connect-source"];
|
||||
for (const verdict of VERDICTS) {
|
||||
const entry = block.state[verdict] as
|
||||
{ title?: string; detail?: string } | undefined;
|
||||
expect(entry?.title, `${locale}/${verdict}.title`).toBeTruthy();
|
||||
expect(entry?.detail, `${locale}/${verdict}.detail`).toBeTruthy();
|
||||
}
|
||||
// `disconnected` is the one verdict with no state copy: it renders the
|
||||
// connect card or the credentials note, neither of which is a claim
|
||||
// about a connection that exists.
|
||||
expect(block.state.disconnected).toBeUndefined();
|
||||
expect(block.needsCredentials.title).toBeTruthy();
|
||||
expect(block.openSettings).toBeTruthy();
|
||||
expect(block.noDate).toBeTruthy();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
* which integration a Q4 source is, and which page carries an area the
|
||||
* measurement form cannot log.
|
||||
*/
|
||||
import type { SyncVerdict } from "@/lib/integrations/sync-verdict";
|
||||
import type { OnboardingAreaKey } from "@/lib/modules/registry";
|
||||
|
||||
import type { BrowserConnectableSource } from "./wizard-steps";
|
||||
@@ -47,19 +48,131 @@ export const AREA_PAGE_HREF: Readonly<
|
||||
illness: "/illness",
|
||||
});
|
||||
|
||||
/**
|
||||
* How a source's connection is authorised on this instance, which decides
|
||||
* what "cannot be connected yet" means for it:
|
||||
*
|
||||
* - `byo` — the OAuth app is per-user only (`configured` is the stored client
|
||||
* id/secret pair). With none, the provider card renders a credentials note
|
||||
* and no connect button at all, so offering "Connect" walks the person into
|
||||
* 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.
|
||||
*/
|
||||
export type SourceCredentialModel = "byo" | "shared" | "self-hosted";
|
||||
|
||||
/**
|
||||
* The connections panel's anchor for each connectable source — the `id` of
|
||||
* the provider card on Settings → Integrations, and the key its status is
|
||||
* published under on `/api/integrations/status`.
|
||||
*/
|
||||
export const SOURCE_INTEGRATION: Readonly<
|
||||
Record<BrowserConnectableSource, { anchor: string; statusKey: string }>
|
||||
Record<
|
||||
BrowserConnectableSource,
|
||||
{ anchor: string; statusKey: string; credentials: SourceCredentialModel }
|
||||
>
|
||||
> = Object.freeze({
|
||||
withings: { anchor: "withings", statusKey: "withings" },
|
||||
oura: { anchor: "oura", statusKey: "oura" },
|
||||
whoop: { anchor: "whoop", statusKey: "whoop" },
|
||||
polar: { anchor: "polar", statusKey: "polar" },
|
||||
fitbit: { anchor: "fitbit", statusKey: "fitbit" },
|
||||
strava: { anchor: "strava", statusKey: "strava" },
|
||||
nightscout: { anchor: "nightscout", statusKey: "nightscout" },
|
||||
withings: { anchor: "withings", statusKey: "withings", credentials: "byo" },
|
||||
oura: { anchor: "oura", statusKey: "oura", credentials: "shared" },
|
||||
whoop: { anchor: "whoop", statusKey: "whoop", credentials: "byo" },
|
||||
polar: { anchor: "polar", statusKey: "polar", credentials: "shared" },
|
||||
fitbit: { anchor: "fitbit", statusKey: "fitbit", credentials: "byo" },
|
||||
strava: { anchor: "strava", statusKey: "strava", credentials: "shared" },
|
||||
nightscout: {
|
||||
anchor: "nightscout",
|
||||
statusKey: "nightscout",
|
||||
credentials: "self-hosted",
|
||||
},
|
||||
});
|
||||
|
||||
/** Which tile the connect step renders. */
|
||||
export type ConnectSourceSlot =
|
||||
/** Nothing is connected and something can be: today's connect card. */
|
||||
| "connect"
|
||||
/** Nothing is connected and nothing CAN be: point at the credentials. */
|
||||
| "credentials"
|
||||
/** A working connection — the flow's result, whether or not it is new. */
|
||||
| "result"
|
||||
/** A connection the person has to repair, named rather than painted green. */
|
||||
| "attention";
|
||||
|
||||
/** The fields of an `/api/integrations/status` entry this decision reads. */
|
||||
export interface ConnectSourceStatus {
|
||||
connected?: boolean;
|
||||
configured?: boolean;
|
||||
available?: boolean;
|
||||
state?: string;
|
||||
syncHealth?: { verdict: SyncVerdict; since: string | null };
|
||||
lastSuccessAt?: string | null;
|
||||
}
|
||||
|
||||
export interface ConnectSourceView {
|
||||
slot: ConnectSourceSlot;
|
||||
verdict: SyncVerdict;
|
||||
/**
|
||||
* Whether the flow may record this as the completed first result. Only a
|
||||
* connection that is delivering, or one whose first sync is under way —
|
||||
* never a connection that needs repairing, and never the mere absence of a
|
||||
* ledger row.
|
||||
*/
|
||||
settled: boolean;
|
||||
/** The instant the copy names, or null when there is none to name. */
|
||||
when: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the connect step may honestly say about one source.
|
||||
*
|
||||
* The liveness truth is `syncHealth.verdict` and nothing else — the ledger's
|
||||
* `state` is what the last ATTEMPT did, and its "no row" default used to read
|
||||
* as `connected`, which is how a brand-new account was told its wearable was
|
||||
* connected and had the flow's one result stamped as achieved on its behalf.
|
||||
*
|
||||
* Returns `null` while the envelope has not resolved: an unloaded status is
|
||||
* not evidence of anything, and the screen says nothing rather than guessing.
|
||||
*/
|
||||
export function connectSourceView(
|
||||
source: BrowserConnectableSource,
|
||||
status: ConnectSourceStatus | undefined,
|
||||
): ConnectSourceView | null {
|
||||
if (!status) return null;
|
||||
const verdict = status.syncHealth?.verdict ?? "disconnected";
|
||||
const settled = verdict === "pending_first_sync" || verdict === "fresh";
|
||||
const when =
|
||||
verdict === "fresh" || verdict === "stale"
|
||||
? (status.lastSuccessAt ?? status.syncHealth?.since ?? null)
|
||||
: (status.syncHealth?.since ?? status.lastSuccessAt ?? null);
|
||||
|
||||
if (settled || verdict === "stale") {
|
||||
return { slot: "result", verdict, settled, when };
|
||||
}
|
||||
if (verdict !== "disconnected") {
|
||||
return { slot: "attention", verdict, settled, when };
|
||||
}
|
||||
return {
|
||||
slot: canStartConnection(source, status) ? "connect" : "credentials",
|
||||
verdict,
|
||||
settled,
|
||||
when,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the connect CTA leads anywhere. A `byo` provider without its client
|
||||
* pair and a `shared` provider without any resolvable app both land on a card
|
||||
* that has no connect button — the dead end the flow used to walk into.
|
||||
*/
|
||||
function canStartConnection(
|
||||
source: BrowserConnectableSource,
|
||||
status: ConnectSourceStatus,
|
||||
): boolean {
|
||||
switch (SOURCE_INTEGRATION[source].credentials) {
|
||||
case "byo":
|
||||
return status.configured !== false;
|
||||
case "shared":
|
||||
return status.available !== false;
|
||||
case "self-hosted":
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user