mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
46f9dfa47f
commit
1ddc8a0c47
11 files changed
+295
-168
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user