Surface shipped data sources in onboarding and tell the truth

Rewrite the source step to list the live integrations (Apple Health,
WHOOP, Oura, Polar, Nightscout, Fitbit) alongside Withings and manual
entry, each linking to where it is connected; drop the stale Apple
Health coming-soon badge. Add an import affordance for existing history.

Reword the goals and source copy so it no longer promises automatic
dashboard or reminder configuration that does not happen yet. Add a
calibration line to the done screen setting the expectation that some
insights sharpen over the first weeks, and lead the success screen
forward with connect-a-device and log-first-measurement actions.

Generalize the dashboard checklist data-source item so any connected
integration satisfies it, not Withings alone.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 21:26:09 +02:00
1 parent 46f9dfa47f
commit 1ddc8a0c47
11 files changed
+295 -168

No files matched your search

+35 -4
View File
@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { CheckCircle2 } from "lucide-react";
import { CheckCircle2, FileUp, PlusCircle, Plug } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
@@ -46,11 +46,42 @@ export function DoneScreen() {
<p className="text-muted-foreground mx-auto max-w-md text-base leading-relaxed">
{t("onboarding.done.body")}
</p>
<p className="text-muted-foreground mx-auto max-w-md text-sm leading-relaxed">
{t("onboarding.done.learning")}
</p>
</header>
<Button asChild size="lg">
<Link href="/">{t("onboarding.done.returnCta")}</Link>
</Button>
<div className="flex w-full max-w-xs flex-col gap-2">
<Button asChild size="lg">
<Link
href="/settings/integrations"
className="inline-flex items-center gap-2"
>
<Plug className="size-4" />
{t("onboarding.done.connectCta")}
</Link>
</Button>
<Button asChild size="lg" variant="outline">
<Link
href="/measurements"
className="inline-flex items-center gap-2"
>
<PlusCircle className="size-4" />
{t("onboarding.done.logCta")}
</Link>
</Button>
<Button asChild variant="ghost">
<Link href="/">{t("onboarding.done.returnCta")}</Link>
</Button>
</div>
<Link
href="/settings/export"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-sm underline-offset-4 hover:underline"
>
<FileUp className="size-3.5" />
{t("onboarding.done.importCta")}
</Link>
</section>
);
}
+84 -61
View File
@@ -6,8 +6,9 @@ import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
Apple,
ClipboardCheck,
FileUp,
Plug,
Watch,
Wifi,
type LucideIcon,
@@ -19,29 +20,31 @@ import { cn } from "@/lib/utils";
import { apiPost } from "@/lib/api/api-fetch";
/**
* v1.4.25 W14b-Content — onboarding step 2 (source).
* Onboarding step 2 (source).
*
* Four-card grid presenting the data sources HealthLog supports today
* plus the announce-only Apple Health card so users land on v1.5 with
* the right expectation.
*
* Cards:
* 1. Manual entry (enabled, recommended)
* Presents the data sources HealthLog ships today. The headline cards
* cover the two paths most people start on:
* 1. Manual entry (enabled, recommended — works on any device)
* 2. Withings (enabled — opens OAuth `/api/withings/connect` in a new
* tab; the popup hands control back via the existing callback)
* 3. Apple Health (DISABLED — "Coming with v1.5 iOS app")
* 4. Garmin — omitted entirely. Not on the v1.5 roadmap so showing
* a disabled card adds noise without conveying a real promise.
* tab; the callback flips the connection server-side)
*
* Below them a calm "more sources" row lists the remaining shipped
* integrations (Apple Health, WHOOP, Oura, Polar, Nightscout, Fitbit),
* each linking to Settings → Integrations where it is actually
* connected. We don't try to run six OAuth flows inside the wizard;
* the row sets the expectation and hands off. A discreet import link
* points at the Apple Health export.zip path — the cold-start escape
* hatch for users with existing history.
*
* Garmin is omitted: not shipped, so a card would over-claim.
*
* Selection is non-binding — Manual is the implicit default and the
* Next CTA always advances. The Withings card opens the OAuth flow in
* a new tab; the user returns to this step to click Next when they're
* back (callback handler flips the connection server-side, no client
* polling needed for the wizard contract).
* Next CTA always advances. Nothing here is auto-configured; the step
* only signposts where each source is connected.
*/
interface SourceCard {
slug: "manual" | "withings" | "apple-health";
slug: "manual" | "withings";
Icon: LucideIcon;
titleKey: string;
bodyKey: string;
@@ -49,14 +52,11 @@ interface SourceCard {
* One of:
* - "enabled-select": tap selects the card (Manual)
* - "enabled-oauth": tap opens OAuth in a new tab (Withings)
* - "disabled": semi-transparent, badge, no-op (Apple Health)
*/
state: "enabled-select" | "enabled-oauth" | "disabled";
state: "enabled-select" | "enabled-oauth";
/** External href for `enabled-oauth`. */
href?: string;
/** Optional badge under the title (e.g. "Coming with v1.5"). */
badgeKey?: string;
/** Optional recommended badge ("Most popular"). */
/** Optional recommended badge. */
recommendedKey?: string;
}
@@ -77,14 +77,19 @@ const SOURCE_CARDS: ReadonlyArray<SourceCard> = [
state: "enabled-oauth",
href: "/api/withings/connect",
},
{
slug: "apple-health",
Icon: Apple,
titleKey: "onboarding.source.appleHealth.title",
bodyKey: "onboarding.source.appleHealth.body",
state: "disabled",
badgeKey: "onboarding.source.appleHealth.badge",
},
];
/**
* The remaining shipped integrations. Listed compactly so the step
* stays calm — the full connect flow for each lives in Settings.
*/
const MORE_SOURCES: ReadonlyArray<{ slug: string; labelKey: string }> = [
{ slug: "apple-health", labelKey: "onboarding.source.more.appleHealth" },
{ slug: "whoop", labelKey: "onboarding.source.more.whoop" },
{ slug: "oura", labelKey: "onboarding.source.more.oura" },
{ slug: "polar", labelKey: "onboarding.source.more.polar" },
{ slug: "nightscout", labelKey: "onboarding.source.more.nightscout" },
{ slug: "fitbit", labelKey: "onboarding.source.more.fitbit" },
];
export function SourceCardGrid() {
@@ -140,13 +145,58 @@ export function SourceCardGrid() {
recommendedLabel={
card.recommendedKey ? t(card.recommendedKey) : null
}
badgeLabel={card.badgeKey ? t(card.badgeKey) : null}
withingsCtaLabel={t("onboarding.source.withings.cta")}
disabledHintLabel={t("onboarding.source.appleHealth.disabledHint")}
/>
))}
</div>
<div className="space-y-3 rounded-lg border border-dashed border-border p-4">
<div className="flex items-start gap-3">
<span
aria-hidden="true"
className="bg-muted text-muted-foreground flex size-9 shrink-0 items-center justify-center rounded-md"
>
<Plug className="size-4" />
</span>
<div className="min-w-0 flex-1 space-y-1">
<h2 className="text-base font-semibold tracking-tight">
{t("onboarding.source.more.title")}
</h2>
<p className="text-muted-foreground text-sm leading-relaxed">
{t("onboarding.source.more.body")}
</p>
</div>
</div>
<ul className="flex flex-wrap gap-2" data-testid="source-more-list">
{MORE_SOURCES.map((item) => (
<li key={item.slug}>
<Button
asChild
variant="outline"
size="sm"
className="min-h-9"
>
<Link
href="/settings/integrations"
data-testid={`source-more-${item.slug}`}
>
{t(item.labelKey)}
</Link>
</Button>
</li>
))}
</ul>
</div>
<Link
href="/settings/export"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-sm underline-offset-4 hover:underline"
data-testid="source-import-link"
>
<FileUp className="size-3.5" />
{t("onboarding.source.import")}
</Link>
<div className="flex items-center justify-between gap-2 pt-2">
<Button asChild variant="ghost" className="min-h-11 min-w-11">
<Link href="/onboarding/1">{t("onboarding.shell.back")}</Link>
@@ -182,9 +232,7 @@ interface SourceCardItemProps {
titleLabel: string;
bodyLabel: string;
recommendedLabel: string | null;
badgeLabel: string | null;
withingsCtaLabel: string;
disabledHintLabel: string;
}
function SourceCardItem({
@@ -194,9 +242,7 @@ function SourceCardItem({
titleLabel,
bodyLabel,
recommendedLabel,
badgeLabel,
withingsCtaLabel,
disabledHintLabel,
}: SourceCardItemProps) {
const Icon = card.Icon;
@@ -211,7 +257,7 @@ function SourceCardItem({
aria-hidden="true"
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-md",
selected && card.state !== "disabled"
selected
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground",
)}
@@ -220,14 +266,7 @@ function SourceCardItem({
</span>
<div className="min-w-0 flex-1">
<h2 className="text-base font-semibold tracking-tight">{titleLabel}</h2>
{badgeLabel ? (
<span
className="bg-muted text-foreground mt-1 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide"
data-testid={`source-badge-${card.slug}`}
>
{badgeLabel}
</span>
) : recommendedLabel ? (
{recommendedLabel ? (
<span className="bg-primary/10 text-primary mt-1 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide">
{recommendedLabel}
</span>
@@ -240,22 +279,6 @@ function SourceCardItem({
<p className="text-muted-foreground text-sm leading-relaxed">{bodyLabel}</p>
);
if (card.state === "disabled") {
return (
<div
aria-disabled="true"
className={cn(baseClasses, "cursor-not-allowed opacity-60")}
data-testid={`source-card-${card.slug}`}
>
{headerRow}
{body}
<p className="text-muted-foreground text-xs italic">
{disabledHintLabel}
</p>
</div>
);
}
if (card.state === "enabled-oauth") {
return (
<div
@@ -37,7 +37,7 @@ const ITEM_ICONS: Record<ChecklistItemId, LucideIcon> = {
profile: User2,
measurement: Activity,
medication: Pill,
withings: Wifi,
dataSource: Wifi,
notifications: Bell,
};
@@ -60,10 +60,10 @@ const ITEM_LABEL_KEYS: Record<
description: "gettingStarted.items.medicationDescription",
cta: "gettingStarted.items.medicationCta",
},
withings: {
title: "gettingStarted.items.withingsTitle",
description: "gettingStarted.items.withingsDescription",
cta: "gettingStarted.items.withingsCta",
dataSource: {
title: "gettingStarted.items.dataSourceTitle",
description: "gettingStarted.items.dataSourceDescription",
cta: "gettingStarted.items.dataSourceCta",
},
notifications: {
title: "gettingStarted.items.notificationsTitle",
@@ -72,8 +72,8 @@ const ITEM_LABEL_KEYS: Record<
},
};
interface WithingsStatus {
connected?: boolean;
interface IntegrationsStatus {
integrations?: Array<{ connected?: boolean; enabled?: boolean }>;
}
interface NotificationChannel {
@@ -227,10 +227,13 @@ export function GettingStartedChecklist() {
// for established users. See docs/audit/v15-performance.md.
const onboardingPending = !!user && user.onboardingCompletedAt == null;
const { data: withingsData } = useQuery<WithingsStatus>({
queryKey: ["withings", "status"],
// v1.17.0 — any connected data source satisfies the step, so we read
// the consolidated integrations envelope (Withings / WHOOP / Fitbit /
// moodLog) rather than the Withings-only status route.
const { data: integrationsData } = useQuery<IntegrationsStatus>({
queryKey: queryKeys.integrationsStatus(),
queryFn: async () => {
return apiGet("/api/withings/status");
return apiGet("/api/integrations/status");
},
enabled: onboardingPending,
});
@@ -244,7 +247,10 @@ export function GettingStartedChecklist() {
});
const medicationCount = medsData?.length ?? 0;
const withingsConnected = withingsData?.connected === true;
const dataSourceConnected = (integrationsData?.integrations ?? []).some(
(integration) =>
integration?.connected === true || integration?.enabled === true,
);
const notificationsConfigured = (notificationsData?.channels ?? []).some(
(channel) => channel?.enabled === true,
);
@@ -259,7 +265,7 @@ export function GettingStartedChecklist() {
},
measurementCount,
medicationCount,
withingsConnected,
dataSourceConnected,
notificationsConfigured,
dismissedIds,
}),
@@ -269,7 +275,7 @@ export function GettingStartedChecklist() {
user?.gender,
measurementCount,
medicationCount,
withingsConnected,
dataSourceConnected,
notificationsConfigured,
dismissedIds,
],
+20 -11
View File
@@ -21,7 +21,7 @@ function inputs(overrides: Partial<Parameters<typeof buildChecklist>[0]> = {}) {
profile: completeProfile,
measurementCount: 0,
medicationCount: 0,
withingsConnected: false,
dataSourceConnected: false,
notificationsConfigured: false,
dismissedIds: new Set<ChecklistItemId>(),
...overrides,
@@ -49,7 +49,7 @@ describe("buildChecklist", () => {
"profile",
"measurement",
"medication",
"withings",
"dataSource",
"notifications",
]);
});
@@ -64,12 +64,21 @@ describe("buildChecklist", () => {
expect(items.find((i) => i.id === "measurement")?.done).toBe(true);
});
it("flags medication and withings independently", () => {
it("flags medication and dataSource independently", () => {
const items = buildChecklist(
inputs({ medicationCount: 2, withingsConnected: true }),
inputs({ medicationCount: 2, dataSourceConnected: true }),
);
expect(items.find((i) => i.id === "medication")?.done).toBe(true);
expect(items.find((i) => i.id === "withings")?.done).toBe(true);
expect(items.find((i) => i.id === "dataSource")?.done).toBe(true);
});
it("dataSource is satisfied by any connected source", () => {
// The predicate is source-agnostic — a single boolean stands in for
// Withings, WHOOP, Oura, Polar, Nightscout, Fitbit or Apple Health.
const off = buildChecklist(inputs({ dataSourceConnected: false }));
expect(off.find((i) => i.id === "dataSource")?.done).toBe(false);
const on = buildChecklist(inputs({ dataSourceConnected: true }));
expect(on.find((i) => i.id === "dataSource")?.done).toBe(true);
});
it("propagates per-item dismissal", () => {
@@ -86,7 +95,7 @@ describe("buildChecklist", () => {
expect(hrefs.profile).toBe("/settings/account");
expect(hrefs.measurement).toBe("/measurements");
expect(hrefs.medication).toBe("/medications");
expect(hrefs.withings).toBe("/settings/integrations");
expect(hrefs.dataSource).toBe("/settings/integrations");
expect(hrefs.notifications).toBe("/settings/notifications");
});
});
@@ -94,10 +103,10 @@ describe("buildChecklist", () => {
describe("visibleChecklist + checklistProgress", () => {
it("hides per-item dismissed rows from the visible list", () => {
const items = buildChecklist(
inputs({ dismissedIds: new Set<ChecklistItemId>(["withings"]) }),
inputs({ dismissedIds: new Set<ChecklistItemId>(["dataSource"]) }),
);
const visible = visibleChecklist(items);
expect(visible.map((i) => i.id)).not.toContain("withings");
expect(visible.map((i) => i.id)).not.toContain("dataSource");
});
it("counts done items inside the visible subset", () => {
@@ -106,7 +115,7 @@ describe("visibleChecklist + checklistProgress", () => {
measurementCount: 3,
medicationCount: 1,
// dismiss the only one not done so percent jumps to 100
dismissedIds: new Set<ChecklistItemId>(["withings", "notifications"]),
dismissedIds: new Set<ChecklistItemId>(["dataSource", "notifications"]),
}),
);
const progress = checklistProgress(items);
@@ -121,7 +130,7 @@ describe("visibleChecklist + checklistProgress", () => {
profile: { heightCm: null, dateOfBirth: null, gender: null },
measurementCount: 0,
medicationCount: 0,
withingsConnected: false,
dataSourceConnected: false,
notificationsConfigured: false,
dismissedIds: new Set(),
});
@@ -185,7 +194,7 @@ describe("shouldShowChecklist", () => {
inputs({
measurementCount: 3,
medicationCount: 1,
withingsConnected: true,
dataSourceConnected: true,
notificationsConfigured: true,
}),
);
+11 -7
View File
@@ -10,7 +10,7 @@ export const CHECKLIST_ITEM_IDS = [
"profile",
"measurement",
"medication",
"withings",
"dataSource",
"notifications",
] as const;
@@ -41,8 +41,12 @@ export interface ChecklistInputs {
measurementCount: number;
/** Number of medications the user has created. */
medicationCount: number;
/** True iff Withings OAuth is connected (status.connected). */
withingsConnected: boolean;
/**
* True iff at least one data source is connected — Withings, WHOOP,
* Oura, Polar, Nightscout, Fitbit, or Apple Health. Any one satisfies
* the "connect a data source" step.
*/
dataSourceConnected: boolean;
/**
* True iff the user has set up at least one notification channel
* (Telegram, ntfy, or Web Push).
@@ -54,7 +58,7 @@ export interface ChecklistInputs {
/**
* Compute the ordered checklist for the dashboard hero. Stable order:
* profile → measurement → medication → withings → notifications. Each
* profile → measurement → medication → dataSource → notifications. Each
* item carries the deep-link the row's CTA should navigate to.
*/
export function buildChecklist(inputs: ChecklistInputs): ChecklistItem[] {
@@ -79,10 +83,10 @@ export function buildChecklist(inputs: ChecklistInputs): ChecklistItem[] {
dismissed: inputs.dismissedIds.has("medication"),
},
{
id: "withings",
done: inputs.withingsConnected,
id: "dataSource",
done: inputs.dataSourceConnected,
href: "/settings/integrations",
dismissed: inputs.dismissedIds.has("withings"),
dismissed: inputs.dismissedIds.has("dataSource"),
},
{
id: "notifications",