mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
fix(onboarding): shorten the AI pitch on the last setup screen
Every new account finished setup on a full AI panel: a gradient badge, a sample briefing, three ways to connect a provider, a one-tap offer for the operator's key and a disclaimer, the most prominent block on the screen. It is now one muted line with a link to Settings, AI, shown under the same rule as the Insights hint (could be set up here, nothing configured yet, not opted out). That hint and the consent request on Insights and Settings, AI stay the places that invite. The sample briefing card, the `onboarding.ai.*` strings in ten locales and the end-to-end spec for the removed offer go with it. The consent-grant guard's floor drops to the two callers left.
This commit is contained in:
1 parent
1d5236a326
commit
b793be3f84
20 files changed
+135
-1379
No files matched your search
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 343,
|
||||
"total": 111236,
|
||||
"count": 342,
|
||||
"total": 110828,
|
||||
"entries": {
|
||||
"app/achievements/page.tsx :: AchievementsPage": 206,
|
||||
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
|
||||
@@ -177,7 +177,6 @@
|
||||
"components/mood/mood-list.tsx :: MoodList": 1109,
|
||||
"components/mood/mood-tag-picker.tsx :: MoodTagPicker": 257,
|
||||
"components/onboarding/confirm-screen.tsx :: ConfirmScreen": 255,
|
||||
"components/onboarding/done-screen.tsx :: DoneScreen": 408,
|
||||
"components/onboarding/getting-started-checklist.tsx :: GettingStartedChecklist": 423,
|
||||
"components/onboarding/tour.tsx :: OnboardingTour": 544,
|
||||
"components/practitioners/practitioner-list.tsx :: PractitionerList": 230,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
|
||||
"count": 2892,
|
||||
"total": 9217,
|
||||
"count": 2891,
|
||||
"total": 9215,
|
||||
"entries": {
|
||||
"__tests__/account-payload-consumer-guard.test.ts": 2,
|
||||
"__tests__/acting-account-boundary-guard.test.ts": 7,
|
||||
@@ -1257,7 +1257,6 @@
|
||||
"components/onboarding/__tests__/anamnesis-card.test.tsx": 1,
|
||||
"components/onboarding/__tests__/confirm-failure-recovery.test.ts": 1,
|
||||
"components/onboarding/__tests__/confirm-screen.test.tsx": 1,
|
||||
"components/onboarding/__tests__/done-screen.test.tsx": 1,
|
||||
"components/onboarding/__tests__/first-result-screen.test.tsx": 2,
|
||||
"components/onboarding/__tests__/getting-started-checklist.test.tsx": 1,
|
||||
"components/onboarding/__tests__/question-screen.test.tsx": 1,
|
||||
@@ -1268,7 +1267,7 @@
|
||||
"components/onboarding/baseline-fields.tsx": 1,
|
||||
"components/onboarding/baseline-form.tsx": 3,
|
||||
"components/onboarding/confirm-screen.tsx": 5,
|
||||
"components/onboarding/done-screen.tsx": 2,
|
||||
"components/onboarding/done-screen.tsx": 1,
|
||||
"components/onboarding/first-result-screen.tsx": 3,
|
||||
"components/onboarding/getting-started-checklist.tsx": 13,
|
||||
"components/onboarding/onboarding-shell.tsx": 1,
|
||||
|
||||
@@ -154,9 +154,10 @@ describe("AI consent is granted only by the person's own tap", () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Non-zero floors: a matcher that stops matching is red, not green.
|
||||
expect(callers).toBeGreaterThanOrEqual(3);
|
||||
expect(sites).toBeGreaterThanOrEqual(3);
|
||||
// Non-zero floors: a matcher that stops matching is red, not green. Two
|
||||
// callers today: the consent request and the document-reading prompt.
|
||||
expect(callers).toBeGreaterThanOrEqual(2);
|
||||
expect(sites).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("keeps both grant routes refusing a request without the confirmation", () => {
|
||||
|
||||
@@ -4,60 +4,20 @@ 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"`).
|
||||
* The setup flow's exit. AI gets one quiet line with a link to Settings, AI,
|
||||
* and only while it could be set up here and is still wanted; the exits
|
||||
* (dashboard, tour, connect, log) are there either way.
|
||||
*/
|
||||
|
||||
const aiProviderState = vi.hoisted(() => ({
|
||||
data: undefined as
|
||||
| {
|
||||
managedBy?: string | null;
|
||||
serverProviderHealth?: string;
|
||||
serverProviderOffer?: boolean;
|
||||
serverProviderConsent?: boolean;
|
||||
}
|
||||
| undefined,
|
||||
}));
|
||||
|
||||
/**
|
||||
* The consent grant is a mutation, and this suite renders to static markup
|
||||
* (the house convention — there is no jsdom in this repo), so the click
|
||||
* itself cannot be dispatched. What IS worth proving is the request the
|
||||
* button would send, so every `useMutation` registration is captured here
|
||||
* and its `mutationFn` invoked directly.
|
||||
*/
|
||||
const mutations = vi.hoisted(
|
||||
() =>
|
||||
[] as Array<{
|
||||
mutationKey?: readonly unknown[];
|
||||
mutationFn?: () => Promise<unknown>;
|
||||
}>,
|
||||
);
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: aiProviderState.data }),
|
||||
useMutation: (options?: {
|
||||
mutationKey?: readonly unknown[];
|
||||
mutationFn?: () => Promise<unknown>;
|
||||
}) => {
|
||||
if (options) mutations.push(options);
|
||||
return { mutate: () => {}, mutateAsync: async () => {}, isPending: false };
|
||||
},
|
||||
useMutation: () => ({
|
||||
mutate: () => {},
|
||||
mutateAsync: async () => {},
|
||||
isPending: false,
|
||||
}),
|
||||
useQueryClient: () => ({ invalidateQueries: async () => {} }),
|
||||
}));
|
||||
|
||||
const apiPost = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
apiPost,
|
||||
}));
|
||||
|
||||
const toastError = vi.hoisted(() => vi.fn());
|
||||
vi.mock("sonner", () => ({ toast: { error: toastError } }));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: () => {}, replace: () => {} }),
|
||||
}));
|
||||
@@ -67,19 +27,23 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: { id: "u1", modules: auth.modules } }),
|
||||
}));
|
||||
|
||||
/**
|
||||
* `ai.provider.canConfigure` from `/api/auth/me`: the panel is one optional
|
||||
* offer, shown only while AI can be set up from here.
|
||||
*/
|
||||
const providerState = vi.hoisted(() => ({ canConfigure: true }));
|
||||
/** `ai.provider` from `/api/auth/me`. */
|
||||
const providerState = vi.hoisted(() => ({
|
||||
configured: false,
|
||||
canConfigure: true,
|
||||
}));
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiProviderState: () => ({
|
||||
configured: false,
|
||||
configured: providerState.configured,
|
||||
managedBy: null,
|
||||
canConfigure: providerState.canConfigure,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/onboarding/use-onboarding-flow", () => ({
|
||||
useOnboardingAnswer: () => ({ mutate: () => {} }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-account-switch", () => ({
|
||||
useAccountSwitch: () => ({ mutate: () => {}, isPending: false }),
|
||||
}));
|
||||
@@ -129,41 +93,64 @@ function render(recordTarget?: OnboardingStateDto["needs"]["recordTarget"]) {
|
||||
);
|
||||
}
|
||||
|
||||
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("AI requests stay on 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("leaves the panel out when AI cannot be set up here", () => {
|
||||
// The operator switched AI off, or the record belongs to somebody else.
|
||||
// HealthLog is complete without AI, so the screen goes from "done"
|
||||
// straight to the exits.
|
||||
providerState.canConfigure = false;
|
||||
aiProviderState.data = undefined;
|
||||
const html = render();
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-panel"');
|
||||
expect(html).toContain('data-slot="onboarding-open-dashboard"');
|
||||
providerState.canConfigure = true;
|
||||
});
|
||||
|
||||
it("leaves the panel out once the Coach and AI analysis are switched off", () => {
|
||||
auth.modules = { coach: false, insights: false };
|
||||
aiProviderState.data = undefined;
|
||||
expect(render()).not.toContain('data-slot="onboarding-ai-panel"');
|
||||
function withState(
|
||||
next: Partial<typeof providerState>,
|
||||
modules: Record<string, boolean>,
|
||||
run: () => void,
|
||||
) {
|
||||
const before = { ...providerState };
|
||||
Object.assign(providerState, next);
|
||||
auth.modules = modules;
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
Object.assign(providerState, before);
|
||||
auth.modules = {};
|
||||
}
|
||||
}
|
||||
|
||||
describe("<DoneScreen> AI line", () => {
|
||||
it("is one line with a link to Settings, AI, and nothing more", () => {
|
||||
const html = render();
|
||||
const line = html.match(
|
||||
/<p[^>]*data-slot="onboarding-ai-line"[^>]*>([\s\S]*?)<\/p>/,
|
||||
);
|
||||
expect(line).not.toBeNull();
|
||||
expect(line![1]).toContain("AI is optional");
|
||||
expect(line![1]).toContain('href="/settings/ai"');
|
||||
// No sample briefing, no provider ladder, no one-tap offer.
|
||||
expect(html).not.toContain("Example");
|
||||
expect(html).not.toContain("Three ways to turn it on");
|
||||
expect(html).not.toMatch(/<button[^>]*>[^<]*Turn on/);
|
||||
});
|
||||
|
||||
it("keeps every exit so the panel is fully skippable", () => {
|
||||
aiProviderState.data = undefined;
|
||||
it("is left out when AI cannot be set up here", () => {
|
||||
// The operator switched AI off, or the record belongs to somebody else.
|
||||
withState({ canConfigure: false }, {}, () => {
|
||||
const html = render();
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-line"');
|
||||
expect(html).toContain('data-slot="onboarding-open-dashboard"');
|
||||
});
|
||||
});
|
||||
|
||||
it("is left out once a provider is configured", () => {
|
||||
withState({ configured: true }, {}, () => {
|
||||
expect(render()).not.toContain('data-slot="onboarding-ai-line"');
|
||||
});
|
||||
});
|
||||
|
||||
it("is left out once the Coach and AI analysis are switched off", () => {
|
||||
withState({}, { coach: false, insights: false }, () => {
|
||||
expect(render()).not.toContain('data-slot="onboarding-ai-line"');
|
||||
});
|
||||
withState({}, { coach: false, insights: true }, () => {
|
||||
expect(render()).toContain('data-slot="onboarding-ai-line"');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("<DoneScreen> exits", () => {
|
||||
it("keeps every exit", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('href="/settings/integrations"');
|
||||
expect(html).toContain('href="/measurements"');
|
||||
@@ -171,229 +158,11 @@ describe("<DoneScreen> AI panel", () => {
|
||||
});
|
||||
|
||||
it("offers the tour rather than launching it", () => {
|
||||
aiProviderState.data = undefined;
|
||||
expect(render()).toContain('data-slot="onboarding-take-tour"');
|
||||
});
|
||||
|
||||
it("offers the managed profile only to an answer that asked for it later", () => {
|
||||
aiProviderState.data = undefined;
|
||||
expect(render("both")).toContain('href="/settings/access"');
|
||||
expect(render("me")).not.toContain('href="/settings/access"');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.38.19 — the shared provider, offered only when it works.
|
||||
*
|
||||
* The card used to read `managedBy === "server"` and promise that insights
|
||||
* "work for you right now". That is a presence read: it knew a key was
|
||||
* configured, never that it worked, and it said nothing about the consent
|
||||
* receipt the egress actually needs — so a fresh account followed the
|
||||
* promise straight into a `consent.ai.required` refusal. On 2026-09-11 the
|
||||
* operator's own instance was answering HTTP 500 while the screen kept
|
||||
* making that promise.
|
||||
*
|
||||
* Four variants now, and only one of them has a button.
|
||||
*/
|
||||
function grantMutation() {
|
||||
return mutations.find((m) =>
|
||||
(m.mutationKey ?? []).some(
|
||||
(part) => typeof part === "string" && part.includes("consent"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe("<DoneScreen> shared-provider offer", () => {
|
||||
it("offers one tap when the server says the provider is known to work", () => {
|
||||
aiProviderState.data = {
|
||||
managedBy: "server",
|
||||
serverProviderHealth: "healthy",
|
||||
serverProviderOffer: true,
|
||||
serverProviderConsent: false,
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toContain('data-ai-state="offer"');
|
||||
expect(html).toContain('data-slot="onboarding-ai-offer"');
|
||||
expect(html).toContain('data-slot="onboarding-ai-offer-grant"');
|
||||
// The unproven promise is gone from this branch.
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-shared-key"');
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-unavailable"');
|
||||
});
|
||||
|
||||
it("says the provider is not answering only when a failure earned it", () => {
|
||||
aiProviderState.data = {
|
||||
managedBy: "server",
|
||||
serverProviderHealth: "unhealthy",
|
||||
serverProviderOffer: false,
|
||||
serverProviderConsent: false,
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toContain('data-ai-state="unavailable"');
|
||||
expect(html).toContain('data-slot="onboarding-ai-unavailable"');
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-offer-grant"');
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-shared-key"');
|
||||
// The rest of the panel is untouched in every branch.
|
||||
expect(html).toContain('data-slot="onboarding-ai-keyless"');
|
||||
expect(html).toContain('href="/settings/ai"');
|
||||
});
|
||||
|
||||
it("claims nothing either way when nobody has measured the provider", () => {
|
||||
// `unknown` is an empty `provider_health` table — the state of every
|
||||
// instance until its first AI call, and the most common way through this
|
||||
// screen. Saying "not answering" there is a claim about something nobody
|
||||
// measured, which is the one thing this panel exists not to do.
|
||||
aiProviderState.data = {
|
||||
managedBy: "server",
|
||||
serverProviderHealth: "unknown",
|
||||
serverProviderOffer: false,
|
||||
serverProviderConsent: false,
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toContain('data-ai-state="neutral"');
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-unavailable"');
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-offer-grant"');
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-shared-key"');
|
||||
// The neutral panel is the whole panel, minus any claim about a provider.
|
||||
expect(html).toContain('data-slot="onboarding-ai-keyless"');
|
||||
expect(html).toContain('href="/settings/ai"');
|
||||
});
|
||||
|
||||
it("keeps the shared-key note for the one state it was ever true of", () => {
|
||||
aiProviderState.data = {
|
||||
managedBy: "server",
|
||||
serverProviderHealth: "healthy",
|
||||
serverProviderOffer: false,
|
||||
serverProviderConsent: true,
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toContain('data-ai-state="consent"');
|
||||
expect(html).toContain('data-slot="onboarding-ai-shared-key"');
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-offer-grant"');
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-unavailable"');
|
||||
});
|
||||
|
||||
it("leaves a user with their own provider the setup link and nothing else", () => {
|
||||
aiProviderState.data = {
|
||||
managedBy: "user",
|
||||
serverProviderHealth: "healthy",
|
||||
serverProviderOffer: false,
|
||||
serverProviderConsent: false,
|
||||
};
|
||||
const html = render();
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-offer"');
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-unavailable"');
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-shared-key"');
|
||||
expect(html).toContain('href="/settings/ai"');
|
||||
});
|
||||
|
||||
it("says nothing at all while the status is still in flight", () => {
|
||||
aiProviderState.data = undefined;
|
||||
const html = render();
|
||||
// Not even the settled marker: the question is genuinely unanswered.
|
||||
expect(html).not.toContain("data-ai-state");
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-offer"');
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-unavailable"');
|
||||
});
|
||||
|
||||
/**
|
||||
* The whole decision table, in one place.
|
||||
*
|
||||
* Three arms the screen can be in — the offer is available, the offer is
|
||||
* withheld for a reason that is not health (the assistant surfaces switched
|
||||
* off, a managed profile), a receipt is already on file — crossed with the
|
||||
* three health states. The point of the cross is that HEALTH decides the
|
||||
* claim in every arm: the consent arm used to promise "insights work for
|
||||
* you right now" without reading health at all, which told somebody who had
|
||||
* consented that a dead provider was working. That is the same unmeasured
|
||||
* claim as the `unknown` case at the other end of the panel.
|
||||
*/
|
||||
const TABLE = [
|
||||
{ arm: "offerable", consent: false, health: "healthy", state: "offer" },
|
||||
{
|
||||
arm: "offerable",
|
||||
consent: false,
|
||||
health: "unhealthy",
|
||||
state: "unavailable",
|
||||
},
|
||||
{ arm: "offerable", consent: false, health: "unknown", state: "neutral" },
|
||||
{ arm: "withheld", consent: false, health: "healthy", state: "neutral" },
|
||||
{
|
||||
arm: "withheld",
|
||||
consent: false,
|
||||
health: "unhealthy",
|
||||
state: "unavailable",
|
||||
},
|
||||
{ arm: "withheld", consent: false, health: "unknown", state: "neutral" },
|
||||
{ arm: "consented", consent: true, health: "healthy", state: "consent" },
|
||||
{
|
||||
arm: "consented",
|
||||
consent: true,
|
||||
health: "unhealthy",
|
||||
state: "unavailable",
|
||||
},
|
||||
{ arm: "consented", consent: true, health: "unknown", state: "neutral" },
|
||||
] as const;
|
||||
|
||||
it.each(TABLE)(
|
||||
"settles on $state when the offer is $arm and the provider is $health",
|
||||
({ arm, consent, health, state }) => {
|
||||
aiProviderState.data = {
|
||||
managedBy: "server",
|
||||
serverProviderHealth: health,
|
||||
// The server only ever offers on `healthy`, and never with a receipt
|
||||
// on file; the withheld arm is the flags-off / managed-profile case.
|
||||
serverProviderOffer: arm === "offerable" && health === "healthy",
|
||||
serverProviderConsent: consent,
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toContain(`data-ai-state="${state}"`);
|
||||
// The positive sentence appears in exactly one of the nine cells.
|
||||
expect(html.includes('data-slot="onboarding-ai-shared-key"')).toBe(
|
||||
state === "consent",
|
||||
);
|
||||
expect(html.includes('data-slot="onboarding-ai-unavailable"')).toBe(
|
||||
state === "unavailable",
|
||||
);
|
||||
expect(html.includes('data-slot="onboarding-ai-offer-grant"')).toBe(
|
||||
state === "offer",
|
||||
);
|
||||
// The rest of the panel is untouched in every cell.
|
||||
expect(html).toContain('data-slot="onboarding-ai-keyless"');
|
||||
expect(html).toContain('href="/settings/ai"');
|
||||
},
|
||||
);
|
||||
|
||||
it("grants only with the explicit confirmation the server requires", async () => {
|
||||
mutations.length = 0;
|
||||
apiPost.mockReset();
|
||||
apiPost.mockResolvedValue({ minted: true, kind: "ai_full" });
|
||||
aiProviderState.data = {
|
||||
managedBy: "server",
|
||||
serverProviderHealth: "healthy",
|
||||
serverProviderOffer: true,
|
||||
serverProviderConsent: false,
|
||||
};
|
||||
render();
|
||||
const grant = grantMutation();
|
||||
expect(grant).toBeDefined();
|
||||
await grant!.mutationFn!();
|
||||
expect(apiPost).toHaveBeenCalledWith("/api/consent/ai/web", {
|
||||
confirm: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("a refused grant throws rather than reporting a consent nobody recorded", async () => {
|
||||
mutations.length = 0;
|
||||
apiPost.mockReset();
|
||||
apiPost.mockRejectedValue(new Error("429"));
|
||||
aiProviderState.data = {
|
||||
managedBy: "server",
|
||||
serverProviderHealth: "healthy",
|
||||
serverProviderOffer: true,
|
||||
serverProviderConsent: false,
|
||||
};
|
||||
render();
|
||||
const grant = grantMutation();
|
||||
await expect(grant!.mutationFn!()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,8 @@
|
||||
|
||||
import { useAiProviderState } from "@/hooks/use-ai-capability";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Compass,
|
||||
@@ -13,21 +11,15 @@ import {
|
||||
PlusCircle,
|
||||
Plug,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
UserPlus,
|
||||
} 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 { StepHeading } from "@/components/onboarding/step-heading";
|
||||
import { useOnboardingAnswer } from "@/components/onboarding/use-onboarding-flow";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useAccountSwitch } from "@/hooks/use-account-switch";
|
||||
import { setTourReferrer } from "@/components/onboarding/tour-launcher";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useGrantAiConsent } from "@/hooks/use-grant-ai-consent";
|
||||
import { hasOptedOutOfAi } from "@/lib/ai/capabilities/types";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { markChecklistExpanded } from "@/lib/onboarding/checklist-storage";
|
||||
@@ -35,21 +27,6 @@ import type { OnboardingStateDto } from "@/lib/onboarding/needs";
|
||||
import { accountLabel } from "@/lib/sharing/account-access-view";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
interface AiProviderStatus {
|
||||
/** Origin of the provider that would serve this user, if any. */
|
||||
managedBy?: "user" | "local" | "server" | null;
|
||||
/**
|
||||
* v1.38.19 — the instance-wide tri-state. Fail closed for the
|
||||
* offer: neither `unhealthy` nor `unknown` earns one. Only `unhealthy` is
|
||||
* a verdict this screen may state out loud.
|
||||
*/
|
||||
serverProviderHealth?: "healthy" | "unhealthy" | "unknown";
|
||||
/** Whether the shared provider may honestly be offered in one tap. */
|
||||
serverProviderOffer?: boolean;
|
||||
/** Whether this user already holds an active `ai_full` / `ai_coach` receipt. */
|
||||
serverProviderConsent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.39 — the setup flow's exit.
|
||||
*
|
||||
@@ -69,18 +46,14 @@ interface AiProviderStatus {
|
||||
* is presentational: the module map was derived on confirm, the cookie
|
||||
* cleared there too.
|
||||
*
|
||||
* The AI panel 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: every exit below stays, and setup is a single optional
|
||||
* deep-link.
|
||||
* AI gets one line with a link to Settings, AI, and no more: HealthLog is
|
||||
* complete without it, and the hint on Insights is where it is offered.
|
||||
*/
|
||||
|
||||
export function DoneScreen({ state }: { state: OnboardingStateDto }) {
|
||||
const { t } = useTranslations();
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const [sampleOpen, setSampleOpen] = useState(false);
|
||||
const answer = useOnboardingAnswer();
|
||||
const accountSwitch = useAccountSwitch();
|
||||
|
||||
@@ -116,87 +89,15 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
|
||||
router.push("/");
|
||||
}
|
||||
|
||||
// ── v1.38.19 — the shared provider, offered only when it works ──
|
||||
//
|
||||
// This block used to be one line: `managedBy === "server"` painted a note
|
||||
// reading "insights work for you right now — no setup needed". Both halves
|
||||
// were wrong. `managedBy` is a PRESENCE read — it knows the operator
|
||||
// configured a key, never that the key answers — and "no setup needed"
|
||||
// skipped the consent receipt `consent-guard.ts` demands before any health
|
||||
// data reaches `admin-openai` / `admin-codex`, so the person who believed
|
||||
// the note walked into a `consent.ai.required` refusal on their first
|
||||
// briefing. On 2026-09-11 the operator's own instance was answering HTTP
|
||||
// 500 from its OAuth proxy while this note kept promising otherwise.
|
||||
//
|
||||
// The server decides now, and the four branches below only READ it. The
|
||||
// rest of the panel — sample, ladder, the "fully useful without AI" line,
|
||||
// the setup link — is identical in every branch; nothing here is a gate.
|
||||
// The panel is one optional offer, shown only while this person could set
|
||||
// AI up here: `provider.canConfigure` is false while the operator has AI
|
||||
// switched off and inside a record somebody else owns. HealthLog is
|
||||
// complete without it, so with nothing to offer (or once the person
|
||||
// switched the Coach and AI analysis off) the screen simply goes from
|
||||
// "done" to the exits.
|
||||
const providerState = useAiProviderState();
|
||||
const showAiPanel =
|
||||
providerState.canConfigure && !hasOptedOutOfAi(user?.modules);
|
||||
const { data: aiProvider } = useQuery<AiProviderStatus>({
|
||||
queryKey: queryKeys.userAiProvider(),
|
||||
queryFn: async () => {
|
||||
return apiGet("/api/user/ai-provider");
|
||||
},
|
||||
enabled: !!user && showAiPanel,
|
||||
});
|
||||
|
||||
// The tap's own outcome, held locally so the line changes the moment the
|
||||
// receipt is minted rather than a refetch later.
|
||||
const [justGranted, setJustGranted] = useState(false);
|
||||
// The tap on the offer is the consent act; the hook sends the explicit
|
||||
// confirmation the server requires before it records one.
|
||||
const grantConsent = useGrantAiConsent();
|
||||
function agreeToSharedProvider() {
|
||||
grantConsent.mutate(undefined, {
|
||||
onSuccess: () => setJustGranted(true),
|
||||
onError: (failure) => {
|
||||
// The button stays. A failed grant is a failed grant; the screen
|
||||
// must not read as though consent were on file.
|
||||
showErrorToast(t("onboarding.ai.offer.error"), { cause: failure });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const operatorProvides = aiProvider?.managedBy === "server";
|
||||
const sharedHealth = aiProvider?.serverProviderHealth;
|
||||
const consentOnFile =
|
||||
justGranted || aiProvider?.serverProviderConsent === true;
|
||||
const offerShared =
|
||||
operatorProvides &&
|
||||
!consentOnFile &&
|
||||
aiProvider?.serverProviderOffer === true;
|
||||
// A receipt on file is a fact about the ACCOUNT; it says nothing about
|
||||
// whether the provider works. This line used to promise "insights work for
|
||||
// you right now" on the strength of the receipt alone, so somebody who had
|
||||
// just consented was told a dead provider was serving them — the same
|
||||
// unmeasured claim as the offer's, made at the other end of the panel. The
|
||||
// positive sentence is now earned by health as well, and a consented
|
||||
// account on a broken provider reads the same honest note as anybody else.
|
||||
const sharedKeyNote =
|
||||
operatorProvides && consentOnFile && sharedHealth === "healthy";
|
||||
// Only a health verdict somebody EARNED paints the "not answering" card —
|
||||
// `unhealthy`, which means the provider was tried and the last thing that
|
||||
// happened was a failure. `unknown` is not that verdict: it is an empty
|
||||
// ledger, which is the state of every instance until its first AI call and
|
||||
// therefore the most common way through this screen. It falls to the
|
||||
// neutral panel, as does every other non-health reason the offer is
|
||||
// withheld (the assistant surfaces switched off, a managed profile whose
|
||||
// provider is the operator's by definition). The neutral panel claims
|
||||
// nothing either way, which is the only honest thing to say about a state
|
||||
// nobody measured.
|
||||
const sharedUnavailable =
|
||||
operatorProvides &&
|
||||
!sharedKeyNote &&
|
||||
!offerShared &&
|
||||
sharedHealth === "unhealthy";
|
||||
// One quiet line, only while this person could set AI up here, has no
|
||||
// provider yet and has not switched the Coach and AI analysis off: the same
|
||||
// rule as the Insights hint, which stays the one place that invites. With
|
||||
// nothing to offer the screen goes from "done" straight to the exits.
|
||||
const provider = useAiProviderState();
|
||||
const showAiLine =
|
||||
!provider.configured &&
|
||||
provider.canConfigure &&
|
||||
!hasOptedOutOfAi(user?.modules);
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -221,169 +122,16 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
{showAiPanel ? (
|
||||
<section
|
||||
aria-labelledby="onboarding-ai-panel-title"
|
||||
data-slot="onboarding-ai-panel"
|
||||
/* Which of the four variants the panel SETTLED on, and absent until
|
||||
it has settled. The panel itself is unconditional and paints
|
||||
immediately, so without this an assertion that a variant is absent
|
||||
passes on a status that has simply not arrived yet. The value is
|
||||
the same tri-state decision made once, above. */
|
||||
data-ai-state={
|
||||
aiProvider === undefined
|
||||
? undefined
|
||||
: sharedKeyNote
|
||||
? "consent"
|
||||
: offerShared
|
||||
? "offer"
|
||||
: sharedUnavailable
|
||||
? "unavailable"
|
||||
: "neutral"
|
||||
}
|
||||
className="border-border bg-card mx-auto flex w-full max-w-md flex-col gap-4 rounded-xl border p-4 text-left md:p-6"
|
||||
{showAiLine ? (
|
||||
<p
|
||||
data-slot="onboarding-ai-line"
|
||||
className="text-muted-foreground max-w-md text-sm"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{sharedKeyNote ? (
|
||||
<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"
|
||||
>
|
||||
{justGranted
|
||||
? t("onboarding.ai.offer.granted")
|
||||
: t("onboarding.ai.sharedKeyNote")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{offerShared ? (
|
||||
<div
|
||||
data-slot="onboarding-ai-offer"
|
||||
className="border-primary/20 bg-primary/5 space-y-2.5 rounded-lg border px-3 py-3"
|
||||
>
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("onboarding.ai.offer.title")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t("onboarding.ai.offer.body")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-9 w-full"
|
||||
data-slot="onboarding-ai-offer-grant"
|
||||
disabled={grantConsent.isPending}
|
||||
onClick={agreeToSharedProvider}
|
||||
>
|
||||
{t("onboarding.ai.offer.cta")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{sharedUnavailable ? (
|
||||
<div
|
||||
data-slot="onboarding-ai-unavailable"
|
||||
className="border-border bg-muted/40 space-y-1 rounded-lg border px-3 py-3"
|
||||
>
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("onboarding.ai.unavailable.title")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t("onboarding.ai.unavailable.body")}
|
||||
</p>
|
||||
</div>
|
||||
) : 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"
|
||||
>
|
||||
{t("onboarding.ai.keylessLine")}
|
||||
</p>
|
||||
|
||||
<MedicalDisclaimer variant="dataPosture" />
|
||||
|
||||
<InlineAction asChild size="sm" className="font-medium underline">
|
||||
<Link href="/settings/ai">{t("onboarding.ai.setupCta")}</Link>
|
||||
{t("onboarding.done.aiOptional")}{" "}
|
||||
<InlineAction asChild size="sm" className="underline">
|
||||
<Link href="/settings/ai">{t("onboarding.done.aiSetupCta")}</Link>
|
||||
</InlineAction>
|
||||
</section>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex w-full max-w-xs flex-col gap-2">
|
||||
|
||||
@@ -263,11 +263,9 @@ export function GettingStartedChecklist() {
|
||||
enabled: checklistRelevant,
|
||||
});
|
||||
|
||||
// The AI row reads the provider state `/api/auth/me` already carries:
|
||||
// present only while AI can be set up from here (`canConfigure`) and the
|
||||
// person has not switched the Coach and AI analysis off, done once any
|
||||
// provider is configured (a personal key, a local model, an OAuth sign-in or
|
||||
// the operator's shared key). No request of its own.
|
||||
// The AI row reads `/api/auth/me`: present while AI can be set up here and
|
||||
// is still wanted (`canConfigure`, `hasOptedOutOfAi`), done once any
|
||||
// provider is configured. No request of its own.
|
||||
const aiProvider = useAiProviderState();
|
||||
|
||||
// v1.5: skip these two fetches once the card can't render at
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
"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")}
|
||||
density="compact"
|
||||
className={cn("bg-muted/30", 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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user