mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
feat(settings): explain operator switches and keep the Coach memory reachable
Settings, AI says which AI features the server's operator switched off and shows the one setup hint while no provider is configured. The consent withdrawal says what it deletes and what stays, and a document-reading consent on file gets its own withdrawal. Regenerate follows the briefing capability and the connection test names an operator refusal. The Coach memory (facts and stored conversations) is never gated on the Coach: Settings, Coach always shows it, and Settings, AI shows it while the Coach page is out of reach, whenever rows exist. Onboarding offers AI only where it can be set up here: the done-screen panel and the getting-started row go away while the operator has AI off or inside somebody else's record.
This commit is contained in:
1 parent
4449b34ac7
commit
3d85445e50
24 files changed
+913
-232
No files matched your search
@@ -66,6 +66,19 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: { id: "u1" } }),
|
||||
}));
|
||||
|
||||
/**
|
||||
* `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 }));
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiProviderState: () => ({
|
||||
configured: false,
|
||||
managedBy: null,
|
||||
canConfigure: providerState.canConfigure,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-account-switch", () => ({
|
||||
useAccountSwitch: () => ({ mutate: () => {}, isPending: false }),
|
||||
}));
|
||||
@@ -129,6 +142,18 @@ describe("<DoneScreen> AI panel", () => {
|
||||
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("keeps every exit so the panel is fully skippable", () => {
|
||||
aiProviderState.data = undefined;
|
||||
const html = render();
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
type OnboardingStateDto,
|
||||
} from "@/lib/onboarding/needs";
|
||||
|
||||
import type { AiProviderState } from "@/lib/ai/capabilities/types";
|
||||
|
||||
let currentUser: AuthUser | null = null;
|
||||
/** Every `enabled` flag the component asked for, keyed by its query key. */
|
||||
const enabledByKey = new Map<string, boolean>();
|
||||
@@ -32,6 +34,16 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
useAccountOnceMounted: () => currentUser,
|
||||
}));
|
||||
|
||||
/** `ai.provider` from `/api/auth/me`, read through `useAiProviderState`. */
|
||||
let providerState: AiProviderState = {
|
||||
configured: true,
|
||||
managedBy: "user",
|
||||
canConfigure: true,
|
||||
};
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiProviderState: () => providerState,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/queries/use-dashboard-snapshot", () => ({
|
||||
useDashboardSnapshot: () => ({
|
||||
data: { tiles: { summaries: { weight: { count: 12 } } } },
|
||||
@@ -40,7 +52,6 @@ vi.mock("@/lib/queries/use-dashboard-snapshot", () => ({
|
||||
|
||||
const RESPONSES: Record<string, unknown> = {
|
||||
'["medications"]': [{ id: "m1" }, { id: "m2" }, { id: "m3" }],
|
||||
'["user","ai-provider"]': { aiAvailable: true },
|
||||
'["integrations","status"]': {
|
||||
integrations: [{ integration: "whoop", connected: true }],
|
||||
},
|
||||
@@ -121,13 +132,24 @@ describe("<GettingStartedChecklist> — rows for an established account", () =>
|
||||
expect(html).toContain("5 of 6 done");
|
||||
});
|
||||
|
||||
it("leaves the AI row out when AI cannot be set up here", () => {
|
||||
// The operator switched AI off, or the record belongs to somebody else:
|
||||
// the list never carries a to-do nobody on this screen can finish.
|
||||
providerState = { configured: false, managedBy: null, canConfigure: false };
|
||||
currentUser = user();
|
||||
const html = render();
|
||||
expect(html).toContain("4 of 5 done");
|
||||
providerState = { configured: true, managedBy: "user", canConfigure: true };
|
||||
});
|
||||
|
||||
it("keeps the supporting queries enabled while the card can render", () => {
|
||||
currentUser = user();
|
||||
render();
|
||||
expect(enabledByKey.get('["medications"]')).toBe(true);
|
||||
expect(enabledByKey.get('["integrations","status"]')).toBe(true);
|
||||
expect(enabledByKey.get('["notifications","preferences"]')).toBe(true);
|
||||
expect(enabledByKey.get('["user","ai-provider"]')).toBe(true);
|
||||
// The AI row reads `/api/auth/me`; it asks for nothing of its own.
|
||||
expect(enabledByKey.has('["user","ai-provider"]')).toBe(false);
|
||||
});
|
||||
|
||||
it("still fetches nothing for a record that never entered the flow and is long past five readings", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useAiProviderState } from "@/hooks/use-ai-capability";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -127,12 +128,19 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
|
||||
// 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 the screen simply goes
|
||||
// from "done" to the exits.
|
||||
const providerState = useAiProviderState();
|
||||
const showAiPanel = providerState.canConfigure;
|
||||
const { data: aiProvider } = useQuery<AiProviderStatus>({
|
||||
queryKey: queryKeys.userAiProvider(),
|
||||
queryFn: async () => {
|
||||
return apiGet("/api/user/ai-provider");
|
||||
},
|
||||
enabled: !!user,
|
||||
enabled: !!user && showAiPanel,
|
||||
});
|
||||
|
||||
// The tap's own outcome, held locally so the line changes the moment the
|
||||
@@ -232,163 +240,165 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
|
||||
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"
|
||||
/* Which of the four variants the panel SETTLED on, and absent until
|
||||
{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"
|
||||
>
|
||||
<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"
|
||||
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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
{t("onboarding.ai.panelTitle")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t("onboarding.ai.panelIntro")}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<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>
|
||||
{sampleOpen ? (
|
||||
<SampleBriefingCard />
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-9 w-full"
|
||||
data-slot="onboarding-ai-offer-grant"
|
||||
disabled={grantConsent.isPending}
|
||||
onClick={() => grantConsent.mutate()}
|
||||
className="w-full"
|
||||
data-slot="onboarding-ai-show-sample"
|
||||
onClick={() => setSampleOpen(true)}
|
||||
>
|
||||
{t("onboarding.ai.offer.cta")}
|
||||
{t("onboarding.ai.showSample")}
|
||||
</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"
|
||||
>
|
||||
{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={() => grantConsent.mutate()}
|
||||
>
|
||||
{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.unavailable.title")}
|
||||
{t("onboarding.ai.ladderTitle")}
|
||||
</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
|
||||
<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>
|
||||
<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>
|
||||
<p
|
||||
data-slot="onboarding-ai-keyless"
|
||||
className="text-foreground text-sm leading-relaxed"
|
||||
>
|
||||
{t("onboarding.ai.keylessLine")}
|
||||
</p>
|
||||
|
||||
<MedicalDisclaimer variant="dataPosture" />
|
||||
<MedicalDisclaimer variant="dataPosture" />
|
||||
|
||||
<Link
|
||||
href="/settings/ai"
|
||||
className="text-primary text-sm font-medium underline underline-offset-4"
|
||||
>
|
||||
{t("onboarding.ai.setupCta")}
|
||||
</Link>
|
||||
</section>
|
||||
<Link
|
||||
href="/settings/ai"
|
||||
className="text-primary text-sm font-medium underline underline-offset-4"
|
||||
>
|
||||
{t("onboarding.ai.setupCta")}
|
||||
</Link>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className="flex w-full max-w-xs flex-col gap-2">
|
||||
<Button
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useAiProviderState } from "@/hooks/use-ai-capability";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -111,15 +112,6 @@ 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 {
|
||||
@@ -270,19 +262,11 @@ 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,
|
||||
});
|
||||
// The AI row reads the provider state `/api/auth/me` already carries:
|
||||
// present only while AI can be set up from here (`canConfigure`), 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.
|
||||
const aiProvider = useAiProviderState();
|
||||
|
||||
// v1.5 perf audit: skip these two fetches once the card can't render at
|
||||
// all (`checklistRelevant`, same gate the meds/AI queries above use).
|
||||
@@ -351,7 +335,8 @@ export function GettingStartedChecklist() {
|
||||
const notificationsConfigured = (notificationsData?.channels ?? []).some(
|
||||
(channel) => channel?.enabled === true,
|
||||
);
|
||||
const insightsConfigured = aiProviderData?.aiAvailable === true;
|
||||
const insightsConfigured = aiProvider.configured;
|
||||
const aiConfigurable = aiProvider.canConfigure;
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
@@ -366,6 +351,7 @@ export function GettingStartedChecklist() {
|
||||
dataSourceConnected,
|
||||
notificationsConfigured,
|
||||
insightsConfigured,
|
||||
aiConfigurable,
|
||||
dismissedIds,
|
||||
upcomingVisitCount: upcomingVisitCount ?? 0,
|
||||
managedProfileCount,
|
||||
@@ -387,6 +373,7 @@ export function GettingStartedChecklist() {
|
||||
dataSourceConnected,
|
||||
notificationsConfigured,
|
||||
insightsConfigured,
|
||||
aiConfigurable,
|
||||
dismissedIds,
|
||||
upcomingVisitCount,
|
||||
managedProfileCount,
|
||||
|
||||
Reference in new issue
Block a user