feat(onboarding): make AI insights reachable early without a gate

Close the gap between the welcome carousel's AI promise and the payoff:
a fresh keyless user used to meet "No AI provider connected" on every AI
surface, because the only setup prompt was a skippable card on the final
screen.

Add an "insights" row to the getting-started checklist, derived from real
provider state — any personal key, local model, OAuth sign-in, or the
operator's shared key flips it done via /api/user/ai-provider. It persists
past onboarding, self-satisfies on connect, and dismisses like the others.

Elevate the done-screen AI card into a value-first panel: it leads with a
static, clearly-labelled example briefing (no model call, no egress, no
consent), then the local-first ladder — local model, bring-your-own-key,
subscription sign-in with the training caveat — and the honest line that
HealthLog is fully useful without AI. All three exits stay, so the panel
gates nothing. On a deployment with a shared provider it says so plainly.

Extract the worked example into a shared SampleBriefingCard. Add
onboarding.ai.* copy across all six locales and retire the old
single-card done-screen keys.
This commit is contained in:
Marc-André Bombeck committed 2026-07-09 20:32:33 +02:00
1 parent 89634e20ee
commit 0adba584a9
14 files changed
+546 -55

No files matched your search

@@ -0,0 +1,83 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
/**
* The elevated done-screen AI panel must lead with value and gate nothing:
* it shows the local-first ladder and the honest "useful without AI" line,
* yet the three original exits (connect / log / dashboard) stay intact, so
* a keyless user is never stranded. The shared-key note only appears when
* the deployment's provider serves the user (`managedBy === "server"`).
*/
const aiProviderState = vi.hoisted(() => ({
data: undefined as { managedBy?: string | null } | undefined,
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: aiProviderState.data }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: { id: "u1" } }),
}));
vi.mock("@/components/onboarding/tour-launcher", () => ({
setTourReferrer: () => {},
}));
vi.mock("next/link", () => ({
default: ({
href,
children,
...rest
}: {
href: string;
children: React.ReactNode;
}) => (
<a href={href} {...rest}>
{children}
</a>
),
}));
import { DoneScreen } from "../done-screen";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<DoneScreen />
</I18nProvider>,
);
}
describe("<DoneScreen> AI panel", () => {
it("leads with the value-first panel and the local-first ladder", () => {
aiProviderState.data = undefined;
const html = render();
expect(html).toContain('data-slot="onboarding-ai-panel"');
// Local model is surfaced first (the calm private default).
expect(html).toContain("nothing leaves your network");
// The honest release valve is present and prominent.
expect(html).toContain('data-slot="onboarding-ai-keyless"');
expect(html).toContain("fully useful without AI");
// Setup is a single optional deep-link, not a gate.
expect(html).toContain('href="/settings/ai"');
});
it("keeps every exit so the panel is fully skippable", () => {
aiProviderState.data = undefined;
const html = render();
expect(html).toContain('href="/settings/integrations"');
expect(html).toContain('href="/measurements"');
expect(html).toContain('href="/"');
});
it("shows the shared-key note only when the operator key serves the user", () => {
aiProviderState.data = { managedBy: "user" };
expect(render()).not.toContain('data-slot="onboarding-ai-shared-key"');
aiProviderState.data = { managedBy: "server" };
expect(render()).toContain('data-slot="onboarding-ai-shared-key"');
});
});
@@ -0,0 +1,41 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { SampleBriefingCard } from "../sample-briefing-card";
/**
* The onboarding "aha" artifact must be a STATIC, clearly-labelled example
* with zero egress: no provider is configured when it renders, so it can
* never make a model call. These tests pin both properties — the "Example"
* tag is present (so it's never mistaken for real data) and nothing on the
* component touches the network.
*/
function render(node: React.ReactElement) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("<SampleBriefingCard>", () => {
it("renders the sample briefing with an explicit Example tag", () => {
const html = render(<SampleBriefingCard />);
expect(html).toContain("Example");
// The worked-example numbers are fixed copy, never derived from a user.
expect(html).toContain("1.2 kg");
expect(html).toContain('data-slot="sample-briefing-tag"');
// The caption states plainly that this is not the user's data.
expect(html).toContain("not your own data");
});
it("makes no network call — no provider exists yet, zero egress", () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
render(<SampleBriefingCard />);
expect(fetchSpy).not.toHaveBeenCalled();
});
});
+127 -20
View File
@@ -1,14 +1,24 @@
"use client";
import Link from "next/link";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { CheckCircle2, FileUp, PlusCircle, Plug, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { MedicalDisclaimer } from "@/components/common/medical-disclaimer";
import { SampleBriefingCard } from "@/components/onboarding/sample-briefing-card";
import { useAuth } from "@/hooks/use-auth";
import { setTourReferrer } from "@/components/onboarding/tour-launcher";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
interface AiProviderStatus {
/** Origin of the provider that would serve this user, if any. */
managedBy?: "user" | "local" | "server" | null;
}
/**
* v1.4.25 W14b-Content — onboarding step 4 (done).
*
@@ -32,11 +42,25 @@ import { useTranslations } from "@/lib/i18n/context";
export function DoneScreen() {
const { t } = useTranslations();
const { user } = useAuth();
const [sampleOpen, setSampleOpen] = useState(false);
useEffect(() => {
if (user?.id) setTourReferrer(user.id);
}, [user?.id]);
// The shared-key note is the ONLY honest divergence in the panel: on a
// deployment that ships an operator key, insights already work for the
// user, so we say so plainly instead of pushing a BYOK/local setup they
// don't need. Presence-only read; no key material is exposed.
const { data: aiProvider } = useQuery<AiProviderStatus>({
queryKey: queryKeys.userAiProvider(),
queryFn: async () => {
return apiGet("/api/user/ai-provider");
},
enabled: !!user,
});
const sharedKeyServes = aiProvider?.managedBy === "server";
return (
<section
aria-labelledby="onboarding-done-title"
@@ -65,32 +89,115 @@ export function DoneScreen() {
</p>
</header>
{/* v1.18.6 — surface the AI Insights / Coach / briefing setup at
the highest-intent moment. The flagship feature needs a BYOK or
local provider that the rest of onboarding never mentions, so a
fresh user otherwise discovers it as a cold error. This card is
skippable by construction — it is an optional deep-link, not a
gate. */}
<div className="border-border/60 bg-muted/30 mx-auto flex w-full max-w-md flex-col items-center gap-2 rounded-lg border p-4 text-center">
<span
aria-hidden="true"
className="from-primary to-brand-pink flex size-9 items-center justify-center rounded-full bg-gradient-to-br"
{/* v1.28 — the flagship AI value, made reachable at the one screen
every fresh user passes through. The daily briefing / Coach need
a provider the rest of onboarding never provisions, so this
leads with the payoff (a static, clearly-labelled SAMPLE
briefing — no model call, no egress, no consent), then the
honest local-first ladder and the "useful without AI" release
valve. Value-first, never a gate: the three exits below stay,
and setup is a single optional deep-link. */}
<section
aria-labelledby="onboarding-ai-panel-title"
data-slot="onboarding-ai-panel"
className="border-border bg-card mx-auto flex w-full max-w-md flex-col gap-4 rounded-xl border p-5 text-left"
>
<header className="flex items-start gap-3">
<span
aria-hidden="true"
className="from-primary to-brand-pink flex size-9 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
>
<Sparkles className="text-background size-4" />
</span>
<div className="space-y-1">
<h2
id="onboarding-ai-panel-title"
className="text-foreground text-base font-semibold tracking-tight"
>
{t("onboarding.ai.panelTitle")}
</h2>
<p className="text-muted-foreground text-sm leading-relaxed">
{t("onboarding.ai.panelIntro")}
</p>
</div>
</header>
{sampleOpen ? (
<SampleBriefingCard />
) : (
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
data-slot="onboarding-ai-show-sample"
onClick={() => setSampleOpen(true)}
>
{t("onboarding.ai.showSample")}
</Button>
)}
{sharedKeyServes ? (
<p
data-slot="onboarding-ai-shared-key"
className="text-foreground bg-primary/5 border-primary/20 rounded-lg border px-3 py-2 text-sm leading-relaxed"
>
{t("onboarding.ai.sharedKeyNote")}
</p>
) : null}
<div className="space-y-2.5">
<p className="text-foreground text-sm font-medium">
{t("onboarding.ai.ladderTitle")}
</p>
<ul className="space-y-2.5">
{/* Local first — the calm, private default — then BYOK, then
the subscription/OAuth path with its training caveat. Same
ordering + vendor-blind framing as the shipped document-
provider governance. */}
<li className="space-y-0.5">
<p className="text-foreground text-sm font-medium">
{t("onboarding.ai.ladderLocalTitle")}
</p>
<p className="text-muted-foreground text-sm leading-relaxed">
{t("onboarding.ai.ladderLocalBody")}
</p>
</li>
<li className="space-y-0.5">
<p className="text-foreground text-sm font-medium">
{t("onboarding.ai.ladderByokTitle")}
</p>
<p className="text-muted-foreground text-sm leading-relaxed">
{t("onboarding.ai.ladderByokBody")}
</p>
</li>
<li className="space-y-0.5">
<p className="text-foreground text-sm font-medium">
{t("onboarding.ai.ladderOauthTitle")}
</p>
<p className="text-muted-foreground text-sm leading-relaxed">
{t("onboarding.ai.ladderOauthBody")}
</p>
</li>
</ul>
</div>
<p
data-slot="onboarding-ai-keyless"
className="text-foreground text-sm leading-relaxed"
>
<Sparkles className="text-background size-4" />
</span>
<p className="text-foreground text-sm font-medium">
{t("onboarding.done.aiTitle")}
</p>
<p className="text-foreground text-sm leading-relaxed">
{t("onboarding.done.aiBody")}
{t("onboarding.ai.keylessLine")}
</p>
<MedicalDisclaimer variant="dataPosture" />
<Link
href="/settings/ai"
className="text-primary text-sm font-medium underline-offset-4 hover:underline"
>
{t("onboarding.done.aiCta")}
{t("onboarding.ai.setupCta")}
</Link>
</div>
</section>
<div className="flex w-full max-w-xs flex-col gap-2">
<Button asChild size="lg">
@@ -9,6 +9,7 @@ import {
Check,
ChevronDown,
Pill,
Sparkles,
User2,
Wifi,
X,
@@ -39,6 +40,7 @@ const ITEM_ICONS: Record<ChecklistItemId, LucideIcon> = {
medication: Pill,
dataSource: Wifi,
notifications: Bell,
insights: Sparkles,
};
const ITEM_LABEL_KEYS: Record<
@@ -70,6 +72,11 @@ const ITEM_LABEL_KEYS: Record<
description: "gettingStarted.items.notificationsDescription",
cta: "gettingStarted.items.notificationsCta",
},
insights: {
title: "gettingStarted.items.insightsTitle",
description: "gettingStarted.items.insightsDescription",
cta: "gettingStarted.items.insightsCta",
},
};
interface IntegrationsStatus {
@@ -85,6 +92,15 @@ interface NotificationsPreferences {
channels?: NotificationChannel[];
}
interface AiProviderStatus {
/**
* True iff any provider can serve the user — personal key, local
* model, OAuth sign-in, or the operator's shared key. Mirrors the
* `insights` checklist row's `done` predicate.
*/
aiAvailable?: boolean;
}
function readDismissedSet(): Set<ChecklistItemId> {
if (typeof window === "undefined") return new Set();
try {
@@ -219,6 +235,20 @@ export function GettingStartedChecklist() {
enabled: checklistRelevant,
});
// v1.28 — the "Turn on insights" row self-satisfies from real provider
// state. `/api/user/ai-provider` reports `aiAvailable` presence-only
// (personal key OR local model OR OAuth OR the operator's shared key),
// so connecting a provider — or landing on a shared-key deployment —
// flips the row done with no client-side credential handling. Gated on
// `checklistRelevant` so it only fetches while the card can render.
const { data: aiProviderData } = useQuery<AiProviderStatus>({
queryKey: queryKeys.userAiProvider(),
queryFn: async () => {
return apiGet("/api/user/ai-provider");
},
enabled: checklistRelevant,
});
// v1.5 perf audit: skip these two fetches once the user is past
// onboarding. The checklist hides itself anyway via shouldShowChecklist
// when onboardingCompletedAt != null AND measurementCount >= 5;
@@ -254,6 +284,7 @@ export function GettingStartedChecklist() {
const notificationsConfigured = (notificationsData?.channels ?? []).some(
(channel) => channel?.enabled === true,
);
const insightsConfigured = aiProviderData?.aiAvailable === true;
const items = useMemo(
() =>
@@ -267,6 +298,7 @@ export function GettingStartedChecklist() {
medicationCount,
dataSourceConnected,
notificationsConfigured,
insightsConfigured,
dismissedIds,
}),
[
@@ -277,6 +309,7 @@ export function GettingStartedChecklist() {
medicationCount,
dataSourceConnected,
notificationsConfigured,
insightsConfigured,
dismissedIds,
],
);
@@ -0,0 +1,57 @@
"use client";
import { Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
/**
* The onboarding "aha" artifact — a static, clearly-labelled *example*
* daily briefing shown BEFORE any AI provider is connected.
*
* It exists so a fresh user feels the payoff of AI Insights with zero
* setup: no provider is configured yet, so this makes no model call, no
* fetch, no egress, and needs no consent. Every number is fixed copy
* from the `onboarding.ai.sample*` keys (the worked example the
* no-provider briefing empty state already carries) — it is never
* derived from the user's own record, and the "Example" badge plus the
* caption make that explicit so it is never mistaken for real data.
*
* Presentational only — no state, no effect, no data fetch. Reused by
* the onboarding done-screen panel; safe to drop into any surface that
* wants to show what a briefing reads like.
*/
export function SampleBriefingCard({ className }: { className?: string }) {
const { t } = useTranslations();
return (
<Card
data-slot="sample-briefing-card"
aria-label={t("onboarding.ai.sampleAriaLabel")}
className={cn("bg-muted/30 gap-2 py-3 md:py-4", className)}
>
<CardHeader>
<TileHeader
icon={Sparkles}
title={t("onboarding.ai.sampleTitle")}
right={
<Badge variant="secondary" data-slot="sample-briefing-tag">
{t("onboarding.ai.sampleTag")}
</Badge>
}
/>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-foreground text-sm leading-relaxed">
{t("onboarding.ai.sampleBody")}
</p>
<p className="text-muted-foreground text-xs leading-relaxed">
{t("onboarding.ai.sampleCaption")}
</p>
</CardContent>
</Card>
);
}