mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
Merge branch 'feat/ai-web-surfaces' into release/v1.39.0
This commit is contained in:
commit
e0ece41e21
120 files changed
+3851
-2027
No files matched your search
@@ -72,10 +72,9 @@ function useUpdateAssistantFlags() {
|
||||
onSuccess: (data) => {
|
||||
client.setQueryData(queryKeys.adminAssistantFlags(), data);
|
||||
// Every record's resolved AI capabilities ride the account payload,
|
||||
// and the deprecated switch projection is still read on the web;
|
||||
// bust both so the operator sees the change within the session.
|
||||
// which is the only thing the web reads them from; bust it so the
|
||||
// operator sees the change within the session.
|
||||
client.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
client.invalidateQueries({ queryKey: queryKeys.featureFlags() });
|
||||
toast.success(t("common.saved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useGrantDocumentReadingConsent } from "@/hooks/use-grant-document-reading-consent";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* The consent question, asked where a document is about to be read.
|
||||
*
|
||||
* Shown in place of a document-reading action while its capability reports
|
||||
* `consent_required`: the chain would send the document to a service outside
|
||||
* this server and no receipt covers that yet. One sentence says what would
|
||||
* happen, one button grants exactly that (`ai_extraction`), and the action
|
||||
* appears once `/api/auth/me` confirms it. Withdrawing stays in Settings → AI.
|
||||
*/
|
||||
export function DocumentReadingConsentPrompt({
|
||||
className,
|
||||
}: {
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const grant = useGrantDocumentReadingConsent();
|
||||
return (
|
||||
<div
|
||||
data-slot="document-reading-consent"
|
||||
className={
|
||||
className ??
|
||||
"border-border flex flex-col items-start gap-2 rounded-lg border border-dashed px-3 py-2.5"
|
||||
}
|
||||
>
|
||||
<p className="text-foreground flex items-start gap-2 text-sm">
|
||||
<ShieldCheck className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
<span>{t("documents.readingConsent.body")}</span>
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={grant.isPending}
|
||||
onClick={() => grant.mutate()}
|
||||
data-slot="document-reading-consent-grant"
|
||||
>
|
||||
{t("documents.readingConsent.grant")}
|
||||
</Button>
|
||||
{grant.isError ? (
|
||||
<p role="alert" className="text-destructive text-xs">
|
||||
{t("documents.readingConsent.error")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -48,7 +48,7 @@ vi.mock("../use-cycle", async () => {
|
||||
await vi.importActual<typeof import("../use-cycle")>("../use-cycle");
|
||||
return {
|
||||
...actual,
|
||||
useCycleCalendar: () => calendarState.current,
|
||||
useCycleRingCalendar: () => calendarState.current,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* The scores strip gives the cycle ring a cell only when the ring would draw
|
||||
* a dial. The ring renders nothing without an active cycle, and a cell
|
||||
* reserved for it (with Strain dropped to make room) sat empty in the row.
|
||||
*/
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { localYmd, useCycleRingHasDial } from "../use-cycle";
|
||||
|
||||
function window(): [string, string] {
|
||||
const from = new Date();
|
||||
from.setDate(from.getDate() - 90);
|
||||
const to = new Date();
|
||||
to.setDate(to.getDate() + 180);
|
||||
return [localYmd(from), localYmd(to)];
|
||||
}
|
||||
|
||||
function Probe({ enabled }: { enabled: boolean }) {
|
||||
return <span>{String(useCycleRingHasDial(enabled))}</span>;
|
||||
}
|
||||
|
||||
function hasDial(verdict: unknown, enabled = true): boolean {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: Infinity, retry: 0 } },
|
||||
});
|
||||
const [from, to] = window();
|
||||
client.setQueryData(queryKeys.cycleCalendar(from, to), { verdict });
|
||||
const html = renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<Probe enabled={enabled} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return html === "<span>true</span>";
|
||||
}
|
||||
|
||||
describe("useCycleRingHasDial", () => {
|
||||
it("is true while a cycle is active today", () => {
|
||||
expect(hasDial({ phase: "follicular", dayOfCycle: 6 })).toBe(true);
|
||||
});
|
||||
|
||||
it("is false without an active cycle, so no empty cell is reserved", () => {
|
||||
expect(hasDial({ phase: null, dayOfCycle: null })).toBe(false);
|
||||
expect(hasDial(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("is false while the cycle surface is hidden", () => {
|
||||
expect(hasDial({ phase: "luteal", dayOfCycle: 20 }, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Droplets } from "lucide-react";
|
||||
|
||||
@@ -8,7 +8,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { CycleRing } from "./cycle-ring";
|
||||
import { PHASE_HUE } from "./phase-tokens";
|
||||
import { localYmd, useCycleCalendar } from "./use-cycle";
|
||||
import { useCycleRingCalendar } from "./use-cycle";
|
||||
|
||||
/**
|
||||
* v1.15.3 — the compact cycle ring as a WELLNESS-STRIP element.
|
||||
@@ -36,22 +36,12 @@ import { localYmd, useCycleCalendar } from "./use-cycle";
|
||||
* strip (no gap, no placeholder).
|
||||
*/
|
||||
|
||||
/** YYYY-MM-DD for `n` days from now in the local tz (shares `localYmd`). */
|
||||
function shiftToday(days: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
return localYmd(d);
|
||||
}
|
||||
|
||||
export function CycleRingTile({ className }: { className?: string }) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// Mirror the cycle-view + summary-card window so the cache key is shared —
|
||||
// no extra request when the user opens the cycle page in the same session.
|
||||
const from = useMemo(() => shiftToday(-90), []);
|
||||
const to = useMemo(() => shiftToday(180), []);
|
||||
|
||||
const calendar = useCycleCalendar(from, to);
|
||||
const calendar = useCycleRingCalendar();
|
||||
// The server already resolved the ring: cycle day, phase, and the arc
|
||||
// proportions (including the profile-derived idealized ring a low-data
|
||||
// tracker gets). The tile renders it.
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* wheel, predictions panel, and history repaint in lockstep after a quick
|
||||
* log.
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -111,6 +112,35 @@ export function useCycleCalendar(from: string, to: string, enabled = true) {
|
||||
});
|
||||
}
|
||||
|
||||
/** YYYY-MM-DD for `days` from today in the local zone. */
|
||||
function localYmdFromToday(days: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
return localYmd(d);
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendar window the cycle ring reads: the cycle page's own window, so
|
||||
* the ring, the page and the summary card share one cache entry.
|
||||
*/
|
||||
export function useCycleRingCalendar(enabled = true) {
|
||||
const from = useMemo(() => localYmdFromToday(-90), []);
|
||||
const to = useMemo(() => localYmdFromToday(180), []);
|
||||
return useCycleCalendar(from, to, enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the cycle ring would draw a dial today. The wellness strip asks
|
||||
* this before it gives the ring a cell (and takes Strain's place for it): the
|
||||
* ring renders nothing without an active cycle, and a cell reserved for it
|
||||
* would sit empty in the row.
|
||||
*/
|
||||
export function useCycleRingHasDial(enabled: boolean): boolean {
|
||||
const calendar = useCycleRingCalendar(enabled);
|
||||
const verdict = calendar.data?.verdict;
|
||||
return enabled && verdict?.phase != null && verdict.dayOfCycle != null;
|
||||
}
|
||||
|
||||
export function useCycleHistory(limit = 24) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.cycleHistory(limit),
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* A refused document read names its reason. Before the capability refusals
|
||||
* had codes of their own, a missing consent, the operator's switch and a
|
||||
* missing provider all fell through to the generic "couldn't read" line, and
|
||||
* the one thing the reader could fix (the consent) was never said.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
|
||||
import { documentAiErrorKey } from "../use-document-assist";
|
||||
|
||||
const refusal = (errorCode: string, status = 403) =>
|
||||
new ApiError("refused", status, { errorCode });
|
||||
|
||||
describe("documentAiErrorKey", () => {
|
||||
it("maps each capability refusal to its own sentence", () => {
|
||||
expect(documentAiErrorKey(refusal("consent.ai.required"))).toBe(
|
||||
"documents.assist.errorConsent",
|
||||
);
|
||||
expect(documentAiErrorKey(refusal("assistant.disabled.documentAi"))).toBe(
|
||||
"documents.assist.errorUnavailable",
|
||||
);
|
||||
expect(documentAiErrorKey(refusal("assistant.disabled.enabled"))).toBe(
|
||||
"documents.assist.errorUnavailable",
|
||||
);
|
||||
expect(documentAiErrorKey(refusal("ai.record.notPermitted"))).toBe(
|
||||
"documents.assist.errorUnavailable",
|
||||
);
|
||||
expect(documentAiErrorKey(refusal("ai.provider.none", 422))).toBe(
|
||||
"documents.assist.errorProvider",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the existing route codes", () => {
|
||||
expect(
|
||||
documentAiErrorKey(refusal("documents.inbound.budgetExceeded", 429)),
|
||||
).toBe("documents.assist.errorBudget");
|
||||
expect(documentAiErrorKey(refusal("something.else", 500))).toBe(
|
||||
"documents.assist.errorGeneric",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -62,6 +62,8 @@ import {
|
||||
apiPost,
|
||||
} from "@/lib/api/api-fetch";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import { DocumentReadingConsentPrompt } from "@/components/ai/document-reading-consent-prompt";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
@@ -389,8 +391,15 @@ export function DocumentDetailSheet({
|
||||
// not, and both of these routes stay on the caller's own authentication. A
|
||||
// delegate would fire two queries that must 403 in order to compute an
|
||||
// affordance every branch below already withholds on `canManageDocuments`.
|
||||
// Reading a document with AI follows the `documentAi` capability on
|
||||
// `/api/auth/me` (the operator's "Reading documents" switch, the provider,
|
||||
// the document-reading consent). Unavailable, the actions are simply not
|
||||
// offered and the probe never fires; a missing consent is asked for in
|
||||
// place instead.
|
||||
const documentAi = useAiCapability("documentAi");
|
||||
const coach = useAiCapability("coach");
|
||||
const capability = useDocumentAiCapability(
|
||||
canManageDocuments && open && documentId !== null,
|
||||
canManageDocuments && open && documentId !== null && documentAi.available,
|
||||
);
|
||||
// When auto-read is ON, reading happens automatically on upload — the manual
|
||||
// per-document AI action row is redundant and collapses away.
|
||||
@@ -558,9 +567,12 @@ export function DocumentDetailSheet({
|
||||
|
||||
// The affordance gate falls back to the capability probe when the sheet is
|
||||
// rendered without the usage-derived props (deep link before usage loads).
|
||||
const aiEnabled = assistAvailable ?? capability.data?.available ?? false;
|
||||
const aiEnabled =
|
||||
documentAi.available &&
|
||||
(assistAvailable ?? capability.data?.available ?? false);
|
||||
const indexEnabled =
|
||||
contentIndexEnabled ?? capability.data?.available ?? false;
|
||||
documentAi.available &&
|
||||
(contentIndexEnabled ?? capability.data?.available ?? false);
|
||||
const autoReadEnabled = autoRead.data?.documentsAutoAiRead ?? false;
|
||||
const aiMode = capability.data?.mode === "text" ? "text" : "vision";
|
||||
const aiTarget: DocumentAiTarget | null = doc
|
||||
@@ -742,7 +754,7 @@ export function DocumentDetailSheet({
|
||||
inside somebody else's record (the shell mounts no drawer
|
||||
there), and `/documents` is a shared-record destination, so
|
||||
this is the Coach button a delegate actually meets. */}
|
||||
{aiEnabled && launch ? (
|
||||
{aiEnabled && coach.available && launch ? (
|
||||
// v1.28.52 (Documents R3) — "Ask the Coach" opens the REAL
|
||||
// coach conversation scoped to this document in the SIDE
|
||||
// DRAWER (the maximize control there expands it to the full
|
||||
@@ -856,7 +868,11 @@ export function DocumentDetailSheet({
|
||||
onGenerate={generateStoredSummary}
|
||||
/>
|
||||
|
||||
{canManageDocuments && (
|
||||
{canManageDocuments && documentAi.reason === "consent_required" ? (
|
||||
<DocumentReadingConsentPrompt />
|
||||
) : null}
|
||||
|
||||
{canManageDocuments && documentAi.available && (
|
||||
<DocumentAiSection
|
||||
aiEnabled={aiEnabled}
|
||||
autoReadEnabled={autoReadEnabled}
|
||||
|
||||
@@ -200,8 +200,19 @@ export function documentAiErrorKey(err: unknown): string {
|
||||
return "documents.assist.errorProvider";
|
||||
case "documents.inbound.notIndexed":
|
||||
return "documents.assist.errorNotIndexed";
|
||||
case "consent.ai.required":
|
||||
return "documents.assist.errorConsent";
|
||||
case "ai.provider.none":
|
||||
return "documents.assist.errorProvider";
|
||||
case "ai.record.notPermitted":
|
||||
case "module.disabled":
|
||||
return "documents.assist.errorUnavailable";
|
||||
default:
|
||||
return "documents.assist.errorGeneric";
|
||||
// The operator switched reading off (`assistant.disabled.documentAi`,
|
||||
// or the master `assistant.disabled.enabled`).
|
||||
return code.startsWith("assistant.disabled.")
|
||||
? "documents.assist.errorUnavailable"
|
||||
: "documents.assist.errorGeneric";
|
||||
}
|
||||
}
|
||||
return "documents.assist.errorGeneric";
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Every Coach entry point follows one answer: the `coach` capability on
|
||||
* `/api/auth/me`.
|
||||
*
|
||||
* The launchers used to AND the operator's switch with the person's Hide
|
||||
* Coach, each on its own, and none of them knew about a missing provider or
|
||||
* a missing consent, so the floating button and the "Ask the Coach" pills
|
||||
* stayed on screen and opened a Coach that could only answer with an error.
|
||||
* Now each surface paints only while the capability is available, and is
|
||||
* gone (no DOM trace, no inert control) for every reason it is not.
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
|
||||
import {
|
||||
AI_UNAVAILABLE_REASONS,
|
||||
type AiCapabilityKey,
|
||||
type AiCapabilityState,
|
||||
} from "@/lib/ai/capabilities/types";
|
||||
import {
|
||||
AI_AVAILABLE,
|
||||
aiUnavailable,
|
||||
} from "@/__tests__/helpers/ai-capability-fixtures";
|
||||
|
||||
const coachState = vi.hoisted(() => ({
|
||||
current: null as AiCapabilityState | null,
|
||||
asked: [] as string[],
|
||||
}));
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: (key: AiCapabilityKey) => {
|
||||
coachState.asked.push(key);
|
||||
return coachState.current;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/insights",
|
||||
}));
|
||||
|
||||
import { AskCoachAction } from "../ask-coach-action";
|
||||
import { CoachLaunchButton } from "../coach-launch-button";
|
||||
import { LayoutCoachFab } from "../layout-coach-fab";
|
||||
import { LayoutCoachMount } from "../layout-coach-mount";
|
||||
|
||||
function render(node: ReactNode): string {
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<CoachLaunchProvider>{node}</CoachLaunchProvider>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
interface Surface {
|
||||
name: string;
|
||||
mount: () => ReactNode;
|
||||
/** What proves it painted; empty for surfaces that SSR to nothing. */
|
||||
proof: string;
|
||||
}
|
||||
|
||||
const SURFACES: Surface[] = [
|
||||
{
|
||||
name: "CoachLaunchButton",
|
||||
mount: () => <CoachLaunchButton />,
|
||||
proof: 'data-slot="coach-launch-inline"',
|
||||
},
|
||||
{
|
||||
name: "AskCoachAction",
|
||||
mount: () => <AskCoachAction question="Why?" />,
|
||||
proof: 'data-slot="ask-coach-action"',
|
||||
},
|
||||
{
|
||||
name: "LayoutCoachFab",
|
||||
mount: () => <LayoutCoachFab />,
|
||||
proof: 'data-slot="coach-fab"',
|
||||
},
|
||||
// The drawer is lazy (next/dynamic) and SSRs to nothing either way; the
|
||||
// capability read is what the test pins for it.
|
||||
{ name: "LayoutCoachMount", mount: () => <LayoutCoachMount />, proof: "" },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
coachState.current = AI_AVAILABLE;
|
||||
coachState.asked = [];
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
describe("Coach entry points follow the coach capability", () => {
|
||||
for (const surface of SURFACES) {
|
||||
it(`${surface.name} paints while the Coach is available`, () => {
|
||||
const html = render(surface.mount());
|
||||
if (surface.proof) expect(html).toContain(surface.proof);
|
||||
expect(coachState.asked).toContain("coach");
|
||||
});
|
||||
|
||||
for (const reason of AI_UNAVAILABLE_REASONS) {
|
||||
it(`${surface.name} is gone when the Coach is unavailable (${reason})`, () => {
|
||||
coachState.current = aiUnavailable(reason);
|
||||
const html = render(surface.mount());
|
||||
if (surface.proof) expect(html).not.toContain(surface.proof);
|
||||
expect(html).not.toMatch(/data-slot="(coach-|ask-coach)/);
|
||||
expect(coachState.asked).toContain("coach");
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -268,28 +268,18 @@ describe("<DailyBriefing>", () => {
|
||||
expect(html).not.toContain('data-slot="daily-briefing-meta-slot"');
|
||||
});
|
||||
|
||||
// v1.15.20 — no-provider state: the regenerate CTA would 422 forever,
|
||||
// so the card points at Settings → AI instead.
|
||||
it("renders the connect-provider hint with a settings link when noProvider", () => {
|
||||
// v1.39 (J1) — the card has no "no provider" state of its own any more.
|
||||
// It only mounts while the briefing capability is available, which
|
||||
// implies a provider; a missing provider is said once by the setup hint on
|
||||
// the overview. The empty state therefore always offers the regenerate CTA.
|
||||
it("never renders a connect-provider state; the empty state offers regenerate", () => {
|
||||
const html = render(
|
||||
<DailyBriefing briefing={null} onRegenerate={() => {}} noProvider />,
|
||||
<DailyBriefing briefing={null} onRegenerate={() => {}} />,
|
||||
);
|
||||
expect(html).toContain('data-slot="daily-briefing-no-provider"');
|
||||
expect(html).toContain("No AI provider connected");
|
||||
expect(html).toMatch(/href="\/settings\/ai"/);
|
||||
// The futile regenerate CTA must NOT render in this state.
|
||||
expect(html).not.toContain('data-slot="daily-briefing-empty-cta"');
|
||||
});
|
||||
|
||||
it("renders the German connect-provider hint under the de locale", () => {
|
||||
const html = render(<DailyBriefing briefing={null} noProvider />, "de");
|
||||
expect(html).toContain("Kein KI-Anbieter verbunden");
|
||||
});
|
||||
|
||||
it("prefers the briefing content over the no-provider hint when both exist", () => {
|
||||
const html = render(<DailyBriefing briefing={baseBriefing} noProvider />);
|
||||
expect(html).toContain('data-slot="daily-briefing-findings"');
|
||||
expect(html).not.toContain('data-slot="daily-briefing-no-provider"');
|
||||
expect(html).not.toContain("daily-briefing-no-provider");
|
||||
expect(html).not.toContain("daily-briefing-stale-no-provider");
|
||||
expect(html).not.toMatch(/href="\/settings\/ai"/);
|
||||
expect(html).toContain('data-slot="daily-briefing-empty-cta"');
|
||||
});
|
||||
|
||||
// v1.25 — generation-failed affordances. The briefing keeps its last good
|
||||
@@ -316,19 +306,6 @@ describe("<DailyBriefing>", () => {
|
||||
expect(html).not.toContain('data-slot="daily-briefing-refresh-failed"');
|
||||
});
|
||||
|
||||
it("suppresses the refresh-failed hint when no provider is connected", () => {
|
||||
const html = render(
|
||||
<DailyBriefing
|
||||
briefing={baseBriefing}
|
||||
onRegenerate={() => {}}
|
||||
generationFailed
|
||||
noProviderStale
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="daily-briefing-refresh-failed"');
|
||||
expect(html).toContain('data-slot="daily-briefing-stale-no-provider"');
|
||||
});
|
||||
|
||||
it("renders the 'couldn't generate' empty state with a retry CTA when failed and empty", () => {
|
||||
const html = render(
|
||||
<DailyBriefing
|
||||
|
||||
@@ -16,6 +16,15 @@ import type { InsightStatusData } from "@/hooks/use-insight-status";
|
||||
* so SSR renders its stub — enough to verify the assessment slot.
|
||||
*/
|
||||
|
||||
// AI surfaces paint only while their capability is available; these tests
|
||||
// cover the painted surface, so every capability reads available here.
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: () => ({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
}),
|
||||
}));
|
||||
vi.mock("next/dynamic", () => ({
|
||||
default: () => {
|
||||
const Stub = () => <div data-slot="healthkit-chart-stub" />;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* A status note is model-written text: the card paints only while the
|
||||
* `statusText` capability is available and is gone, never an error or a
|
||||
* "connect a provider" hint, for every reason it is not. The score anatomy's
|
||||
* assessment is composed from the numbers (`aiAuthored={false}`) and shows
|
||||
* whatever the AI state.
|
||||
*/
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
AI_UNAVAILABLE_REASONS,
|
||||
type AiCapabilityKey,
|
||||
type AiCapabilityState,
|
||||
} from "@/lib/ai/capabilities/types";
|
||||
import {
|
||||
AI_AVAILABLE,
|
||||
aiUnavailable,
|
||||
} from "@/__tests__/helpers/ai-capability-fixtures";
|
||||
|
||||
const capability = vi.hoisted(() => ({
|
||||
current: null as AiCapabilityState | null,
|
||||
asked: [] as AiCapabilityKey[],
|
||||
}));
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: (key: AiCapabilityKey) => {
|
||||
capability.asked.push(key);
|
||||
return capability.current;
|
||||
},
|
||||
}));
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: { timezone: "Europe/Berlin" } }),
|
||||
}));
|
||||
|
||||
import { InsightStatusCard } from "../insight-status-card";
|
||||
|
||||
const baseProps = {
|
||||
title: "Pulse",
|
||||
icon: null,
|
||||
text: "Your pulse is stable.",
|
||||
hasProvider: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
capability.current = AI_AVAILABLE;
|
||||
capability.asked = [];
|
||||
});
|
||||
|
||||
describe("<InsightStatusCard> — statusText capability", () => {
|
||||
it("renders the note while status notes are available", () => {
|
||||
const html = render(<InsightStatusCard {...baseProps} />);
|
||||
expect(html).toContain("Your pulse is stable");
|
||||
expect(capability.asked).toContain("statusText");
|
||||
});
|
||||
|
||||
for (const reason of AI_UNAVAILABLE_REASONS) {
|
||||
it(`renders nothing when status notes are unavailable (${reason})`, () => {
|
||||
capability.current = aiUnavailable(reason);
|
||||
expect(render(<InsightStatusCard {...baseProps} />)).toBe("");
|
||||
});
|
||||
}
|
||||
|
||||
it("never renders a connect-a-provider state", () => {
|
||||
const html = render(
|
||||
<InsightStatusCard {...baseProps} text={null} hasProvider={false} />,
|
||||
);
|
||||
expect(html).not.toContain("/settings/ai");
|
||||
expect(html).not.toContain("insight-status-no-provider-cta");
|
||||
});
|
||||
|
||||
it("keeps a composed assessment visible whatever the AI state", () => {
|
||||
capability.current = aiUnavailable("operator_disabled");
|
||||
const html = render(
|
||||
<InsightStatusCard {...baseProps} aiAuthored={false} />,
|
||||
);
|
||||
expect(html).toContain("Your pulse is stable");
|
||||
});
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { InsightStatusCard } from "../insight-status-card";
|
||||
|
||||
/**
|
||||
* v1.4.31 — the per-metric `<InsightStatusCard>` returns `null` when
|
||||
* the operator has disabled the `insightStatus` surface. Renders
|
||||
* normally otherwise.
|
||||
*/
|
||||
function wrap(node: React.ReactNode, client: QueryClient) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
title: "Pulse",
|
||||
icon: null,
|
||||
text: "Your pulse is stable.",
|
||||
hasProvider: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
|
||||
function makeClient(flags: {
|
||||
enabled: boolean;
|
||||
coach: boolean;
|
||||
briefing: boolean;
|
||||
insightStatus: boolean;
|
||||
documentAi: boolean;
|
||||
}): QueryClient {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: 0 } },
|
||||
});
|
||||
client.setQueryData(["feature-flags"], { assistant: flags });
|
||||
return client;
|
||||
}
|
||||
|
||||
describe("<InsightStatusCard> — assistant.insightStatus gate", () => {
|
||||
it("renders the card when the flag is on", () => {
|
||||
const html = wrap(
|
||||
<InsightStatusCard {...baseProps} />,
|
||||
makeClient({
|
||||
enabled: true,
|
||||
coach: true,
|
||||
briefing: true,
|
||||
insightStatus: true,
|
||||
documentAi: true,
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("Your pulse is stable");
|
||||
});
|
||||
|
||||
it("returns null when the operator disables the sub-flag", () => {
|
||||
const html = wrap(
|
||||
<InsightStatusCard {...baseProps} />,
|
||||
makeClient({
|
||||
enabled: true,
|
||||
coach: true,
|
||||
briefing: true,
|
||||
insightStatus: false,
|
||||
documentAi: true,
|
||||
}),
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("returns null when the master flag is off", () => {
|
||||
const html = wrap(
|
||||
<InsightStatusCard {...baseProps} />,
|
||||
makeClient({
|
||||
enabled: false,
|
||||
coach: false,
|
||||
briefing: false,
|
||||
insightStatus: false,
|
||||
documentAi: false,
|
||||
}),
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,17 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// The card paints only while the `statusText` capability is available; these
|
||||
// tests are about the painted card, so the capability reads available.
|
||||
// `insight-status-card-capability-gate.test.tsx` owns the gate itself.
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: () => ({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
/**
|
||||
* v1.4.27 — F16 regression cover. The per-metric status text comes
|
||||
* out of the AI provider with a literal `metric:<TYPE>` token (the
|
||||
@@ -82,17 +93,6 @@ describe("<InsightStatusCard>", () => {
|
||||
expect(html).toContain("Pulse stayed inside the band.");
|
||||
});
|
||||
|
||||
it("renders the no-provider setup state when no terminal text exists", () => {
|
||||
const html = render(
|
||||
<InsightStatusCard {...baseProps} hasProvider={false} text={null} />,
|
||||
);
|
||||
// v1.18.6 — the no-provider tile is a guided-setup explainer + a
|
||||
// Settings link, not a bare "unavailable" line.
|
||||
expect(html).toContain("Connect an AI provider");
|
||||
expect(html).toContain("Open AI settings");
|
||||
expect(html).toContain("/settings/ai");
|
||||
});
|
||||
|
||||
it("renders a screened terminal fallback even when its envelope has no provider", () => {
|
||||
const html = render(
|
||||
<InsightStatusCard
|
||||
@@ -159,16 +159,13 @@ describe("<InsightStatusCard>", () => {
|
||||
expect(html).not.toContain("animate-spin");
|
||||
});
|
||||
|
||||
it("keeps loading, preparing, no-provider, empty, and populated states distinct with stable compact geometry", () => {
|
||||
it("keeps loading, preparing, empty, and populated states distinct with stable compact geometry", () => {
|
||||
const loading = render(
|
||||
<InsightStatusCard {...baseProps} text={null} loading />,
|
||||
);
|
||||
const preparing = render(
|
||||
<InsightStatusCard {...baseProps} text={null} preparing />,
|
||||
);
|
||||
const noProvider = render(
|
||||
<InsightStatusCard {...baseProps} hasProvider={false} text={null} />,
|
||||
);
|
||||
const empty = render(<InsightStatusCard {...baseProps} text={null} />);
|
||||
const populated = render(
|
||||
<InsightStatusCard
|
||||
@@ -179,11 +176,10 @@ describe("<InsightStatusCard>", () => {
|
||||
|
||||
expect(loading).toContain('data-testid="insight-status-card-loading"');
|
||||
expect(preparing).toContain('data-testid="insight-status-card-preparing"');
|
||||
expect(noProvider).toContain('data-slot="insight-status-no-provider-cta"');
|
||||
expect(empty).toContain("No assessment yet.");
|
||||
expect(populated).toContain('data-slot="insight-assessment"');
|
||||
|
||||
for (const html of [loading, preparing, noProvider, empty]) {
|
||||
for (const html of [loading, preparing, empty]) {
|
||||
expect(html).toContain("gap-2");
|
||||
expect(html).toContain("py-3");
|
||||
expect(html).toContain("md:py-4");
|
||||
|
||||
@@ -5,6 +5,15 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
// navigation hooks the FAB uses. The pathname is mutable so the
|
||||
// hide-on-coach-page branch can be exercised.
|
||||
let mockPathname = "/insights";
|
||||
// AI surfaces paint only while their capability is available; these tests
|
||||
// cover the painted surface, so every capability reads available here.
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: () => ({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
}),
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => mockPathname,
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Sparkles, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { useAiProviderState } from "@/hooks/use-ai-capability";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* The one place HealthLog mentions that AI could be set up.
|
||||
*
|
||||
* HealthLog works without AI, so a missing provider is not a problem to
|
||||
* report on every card. The per-card "connect an AI provider" states are
|
||||
* gone; this calm hint replaces them, on the Insights overview and in
|
||||
* Settings → AI, and nowhere else.
|
||||
*
|
||||
* It shows only while no provider is configured AND the person could set one
|
||||
* up here (`provider.canConfigure`: false inside somebody else's record and
|
||||
* while the operator has AI switched off, where the hint would point at a
|
||||
* setting nobody on this screen can change). Dismissing it is remembered per
|
||||
* viewer in this browser; the hint stays gone on both surfaces.
|
||||
*/
|
||||
const AI_SETUP_HINT_STORAGE_PREFIX = "healthlog.ai-setup-hint.dismissed";
|
||||
|
||||
function storageKey(viewerId: string): string {
|
||||
return `${AI_SETUP_HINT_STORAGE_PREFIX}.${viewerId}`;
|
||||
}
|
||||
|
||||
function readDismissed(viewerId: string): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(storageKey(viewerId)) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function AiSetupHint({
|
||||
surface = "insights",
|
||||
}: {
|
||||
/**
|
||||
* `insights`: a tile on the overview with a link to Settings → AI.
|
||||
* `settings`: a Settings card on Settings → AI itself, with no link.
|
||||
*/
|
||||
surface?: "insights" | "settings";
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const provider = useAiProviderState();
|
||||
// Storage is browser-only: nothing renders before hydration, so the server
|
||||
// HTML and the first client render agree.
|
||||
const mounted = useMounted();
|
||||
const [dismissedNow, setDismissedNow] = useState(false);
|
||||
|
||||
const viewerId = user?.id ?? null;
|
||||
if (!mounted || viewerId === null) return null;
|
||||
if (provider.configured || !provider.canConfigure) return null;
|
||||
if (dismissedNow || readDismissed(viewerId)) return null;
|
||||
|
||||
const dismiss = () => {
|
||||
setDismissedNow(true);
|
||||
try {
|
||||
window.localStorage.setItem(storageKey(viewerId), "1");
|
||||
} catch {
|
||||
// Storage unavailable (private mode): hidden for this visit only.
|
||||
}
|
||||
};
|
||||
|
||||
if (surface === "settings") {
|
||||
return (
|
||||
<SettingsCard data-slot="ai-setup-hint">
|
||||
<SettingsCardHeader
|
||||
icon={Sparkles}
|
||||
title={t("insights.aiSetupHint.title")}
|
||||
/>
|
||||
<p className="text-foreground text-sm">
|
||||
{t("insights.aiSetupHint.settingsBody")}
|
||||
</p>
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={dismiss}
|
||||
data-slot="ai-setup-hint-dismiss"
|
||||
>
|
||||
{t("insights.aiSetupHint.dismiss")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card data-slot="ai-setup-hint" className="gap-2 py-3 md:py-4">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={Sparkles}
|
||||
title={t("insights.aiSetupHint.title")}
|
||||
right={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={dismiss}
|
||||
aria-label={t("insights.aiSetupHint.dismiss")}
|
||||
title={t("insights.aiSetupHint.dismiss")}
|
||||
data-slot="ai-setup-hint-dismiss"
|
||||
className="-my-2 shrink-0"
|
||||
>
|
||||
<X className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-start gap-3">
|
||||
<p className="text-foreground text-sm">
|
||||
{t("insights.aiSetupHint.body")}
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Link href="/settings/ai" data-slot="ai-setup-hint-link">
|
||||
{t("insights.aiSetupHint.action")}
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -15,8 +15,7 @@ import {
|
||||
useCoachLaunch,
|
||||
type CoachLaunchScope,
|
||||
} from "@/lib/insights/coach-launch-context";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useDisableCoach } from "@/hooks/use-disable-coach";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
|
||||
/**
|
||||
* v1.21.0 (C4 H2) — discreet "Ask the Coach about this" affordance for
|
||||
@@ -71,15 +70,13 @@ export function AskCoachAction({
|
||||
}: AskCoachActionProps) {
|
||||
const { t } = useTranslations();
|
||||
const launch = useCoachLaunch();
|
||||
const flags = useFeatureFlags();
|
||||
const disableCoach = useDisableCoach();
|
||||
const coach = useAiCapability("coach");
|
||||
|
||||
// Same gate posture as <CoachLaunchButton> / <LayoutCoachFab>: render
|
||||
// nothing unless the provider is mounted, the operator flag is on, and
|
||||
// the user has not opted out.
|
||||
// nothing unless the launch provider is mounted and the `coach`
|
||||
// capability is available.
|
||||
if (!launch) return null;
|
||||
if (!flags.coach) return null;
|
||||
if (disableCoach) return null;
|
||||
if (!coach.available) return null;
|
||||
|
||||
const accessibleLabel = label ?? t("insights.coach.askAboutThis");
|
||||
|
||||
@@ -125,12 +122,10 @@ export function AskCoachIconButton({
|
||||
}: AskCoachActionProps) {
|
||||
const { t } = useTranslations();
|
||||
const launch = useCoachLaunch();
|
||||
const flags = useFeatureFlags();
|
||||
const disableCoach = useDisableCoach();
|
||||
const coach = useAiCapability("coach");
|
||||
|
||||
if (!launch) return null;
|
||||
if (!flags.coach) return null;
|
||||
if (disableCoach) return null;
|
||||
if (!coach.available) return null;
|
||||
|
||||
const accessibleLabel = label ?? t("insights.coach.askAboutThis");
|
||||
|
||||
|
||||
@@ -7,8 +7,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useDisableCoach } from "@/hooks/use-disable-coach";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
|
||||
/**
|
||||
* Inline desktop Coach launch button.
|
||||
@@ -78,20 +77,16 @@ export function CoachLaunchButton({
|
||||
}: CoachLaunchButtonProps) {
|
||||
const { t } = useTranslations();
|
||||
const launch = useCoachLaunch();
|
||||
const flags = useFeatureFlags();
|
||||
const disableCoach = useDisableCoach();
|
||||
const coach = useAiCapability("coach");
|
||||
|
||||
if (!launch) {
|
||||
// The button only makes sense beneath the provider. Render nothing
|
||||
// so the sub-page doesn't paint a dead control.
|
||||
return null;
|
||||
}
|
||||
// v1.4.31 — operator can hide the Coach surface app-wide.
|
||||
if (!flags.coach) return null;
|
||||
// v1.4.47 W3 — per-user opt-out is a peer gate to the operator's
|
||||
// flag; either being off hides the pill entirely. See
|
||||
// Hidden while the `coach` capability is unavailable for any reason. See
|
||||
// `<LayoutCoachFab>` for the matching FAB gate.
|
||||
if (disableCoach) return null;
|
||||
if (!coach.available) return null;
|
||||
|
||||
const accessibleLabel = label ?? t("insights.heroActionAskCoach");
|
||||
|
||||
|
||||
@@ -734,6 +734,33 @@ describe("errorCodeToI18nKey", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// v1.39 — a refused turn names its reason. The capability refusals carry
|
||||
// their code in `meta.errorCode`; each maps to copy that says what is off
|
||||
// and who can change it, never the generic "provider unavailable".
|
||||
it("maps each AI capability refusal to its own copy", () => {
|
||||
expect(errorCodeToI18nKey("consent.ai.required")).toBe(
|
||||
"insights.coach.errorConsent",
|
||||
);
|
||||
expect(errorCodeToI18nKey("ai.record.notPermitted")).toBe(
|
||||
"insights.coach.errorNotPermitted",
|
||||
);
|
||||
expect(errorCodeToI18nKey("ai.unavailable")).toBe(
|
||||
"insights.coach.errorUnavailable",
|
||||
);
|
||||
expect(errorCodeToI18nKey("ai.provider.none")).toBe(
|
||||
"insights.coach.errorNoProvider",
|
||||
);
|
||||
expect(errorCodeToI18nKey("assistant.disabled.coach")).toBe(
|
||||
"insights.coach.errorOperatorOff",
|
||||
);
|
||||
expect(errorCodeToI18nKey("assistant.disabled.enabled")).toBe(
|
||||
"insights.coach.errorOperatorOff",
|
||||
);
|
||||
expect(errorCodeToI18nKey("module.disabled")).toBe(
|
||||
"insights.coach.errorCoachOff",
|
||||
);
|
||||
});
|
||||
|
||||
it("forward-compats unknown codes with the namespaced key", () => {
|
||||
expect(errorCodeToI18nKey("errorProvider")).toBe(
|
||||
"insights.coach.errorProvider",
|
||||
|
||||
@@ -99,7 +99,23 @@ export function errorCodeToI18nKey(code: string): string {
|
||||
case "coach.provider.empty":
|
||||
case "coach.stream":
|
||||
return "insights.coach.errorProvider";
|
||||
// v1.39 — the capability refusals (`meta.errorCode` on a 403/422/503
|
||||
// before the stream opens). Each says what is off and who can change it.
|
||||
case "consent.ai.required":
|
||||
return "insights.coach.errorConsent";
|
||||
case "ai.record.notPermitted":
|
||||
return "insights.coach.errorNotPermitted";
|
||||
case "ai.unavailable":
|
||||
return "insights.coach.errorUnavailable";
|
||||
case "ai.provider.none":
|
||||
return "insights.coach.errorNoProvider";
|
||||
case "module.disabled":
|
||||
return "insights.coach.errorCoachOff";
|
||||
default:
|
||||
// The operator switched the Coach (or all AI) off on this server.
|
||||
if (code.startsWith("assistant.disabled.")) {
|
||||
return "insights.coach.errorOperatorOff";
|
||||
}
|
||||
// Forward-compat: try `insights.coach.<code>` for codes that
|
||||
// ship their own translation (e.g. legacy `errorProvider`).
|
||||
return `insights.coach.${code}`;
|
||||
@@ -756,7 +772,8 @@ function ChatBubbleImpl({
|
||||
setup gap, not a transient failure: surface a direct link to
|
||||
Settings → AI so the Coach guides the user into BYOK / local
|
||||
setup instead of inviting an endless retry. */}
|
||||
{errorCode === "coach.provider.none" && (
|
||||
{(errorCode === "coach.provider.none" ||
|
||||
errorCode === "ai.provider.none") && (
|
||||
<Link
|
||||
href="/settings/ai"
|
||||
data-slot="coach-no-provider-cta"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { useCoachConversation } from "./use-coach";
|
||||
|
||||
/**
|
||||
* One stored conversation, read back as plain text while the Coach itself is
|
||||
* unavailable. The messages render as React text children, like every other
|
||||
* Coach surface.
|
||||
*/
|
||||
export function ConversationTranscript({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading, isError, refetch } = useCoachConversation(id);
|
||||
if (isLoading) {
|
||||
return (
|
||||
<p className="text-muted-foreground px-3 pb-3 text-xs">
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<QueryErrorRow
|
||||
className="mx-3 mb-3"
|
||||
onRetry={() => void refetch()}
|
||||
slot="coach-conversations-transcript-error"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ol
|
||||
data-slot="coach-conversations-transcript"
|
||||
className="flex flex-col gap-3 px-3 pb-3"
|
||||
>
|
||||
{data.messages.map((message) => (
|
||||
<li key={message.id} className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
{message.role === "user"
|
||||
? t("insights.coach.transcriptYou")
|
||||
: t("insights.coach.transcriptCoach")}
|
||||
</span>
|
||||
<p className="text-foreground text-sm whitespace-pre-wrap">
|
||||
{message.content}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@@ -950,8 +950,13 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
try {
|
||||
const envelope = (await response.clone().json()) as {
|
||||
error?: unknown;
|
||||
meta?: { errorCode?: unknown } | null;
|
||||
};
|
||||
if (typeof envelope?.error === "string") {
|
||||
// v1.39 — a capability refusal carries its code in
|
||||
// `meta.errorCode` and a sentence in `error`; the code wins.
|
||||
if (typeof envelope?.meta?.errorCode === "string") {
|
||||
structured = envelope.meta.errorCode;
|
||||
} else if (typeof envelope?.error === "string") {
|
||||
structured = envelope.error;
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AlertTriangle, Activity } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
|
||||
import { CoverageMeter } from "@/components/insights/derived/coverage-meter";
|
||||
import { ProvenanceExplainer } from "@/components/insights/derived/provenance-explainer";
|
||||
@@ -172,11 +173,32 @@ export function CoincidentDeviationCard({
|
||||
className,
|
||||
}: CoincidentDeviationCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const { data } = useDerivedMetric<CoincidentDeviationValue>(
|
||||
const { data, isError, refetch } = useDerivedMetric<CoincidentDeviationValue>(
|
||||
COINCIDENT_METRIC,
|
||||
{ enabled },
|
||||
);
|
||||
|
||||
// A failed read says so with a retry. Without this branch the card sat on
|
||||
// its skeleton forever, which read as "still loading" rather than failed.
|
||||
if (isError && !data) {
|
||||
return (
|
||||
<section
|
||||
data-slot="coincident-deviation-section"
|
||||
aria-label={t("insights.derived.coincident.cardTitle")}
|
||||
className={cn("space-y-3", className)}
|
||||
>
|
||||
<SectionHeading
|
||||
icon={Activity}
|
||||
title={t("insights.derived.coincident.cardTitle")}
|
||||
/>
|
||||
<QueryErrorRow
|
||||
slot="coincident-deviation-error"
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// CLS-safe placeholder while the single read is in flight.
|
||||
if (!data) {
|
||||
return <CardSkeleton className={className} />;
|
||||
|
||||
@@ -107,21 +107,6 @@ interface DailyBriefingProps {
|
||||
onRegenerate?: () => void;
|
||||
/** Disables the regenerate CTA while a generation is in flight. */
|
||||
regenerating?: boolean;
|
||||
/**
|
||||
* v1.15.20 — no AI provider is configured anywhere, so generating is
|
||||
* futile. The empty state swaps the regenerate CTA for a quiet hint
|
||||
* linking to Settings → AI instead of an eternal "preparing" loop.
|
||||
*/
|
||||
noProvider?: boolean;
|
||||
/**
|
||||
* v1.18.9 (#4) — no AI provider configured WHILE a (stale) cached
|
||||
* briefing is still shown. The read path serves the last good briefing
|
||||
* regardless of provider state, so a provider-less account keeps seeing
|
||||
* a days-old briefing. When true, the footer pairs the honest relative
|
||||
* age with a discreet "this can't refresh — connect a provider" hint so
|
||||
* the staleness reads as intentional, not as a live report.
|
||||
*/
|
||||
noProviderStale?: boolean;
|
||||
/**
|
||||
* v1.25 — the most recent generation attempt failed (provider timeout /
|
||||
* error). The briefing keeps its last good text, so this never blanks a
|
||||
@@ -365,8 +350,6 @@ export function DailyBriefing({
|
||||
loading = false,
|
||||
onRegenerate,
|
||||
regenerating = false,
|
||||
noProvider = false,
|
||||
noProviderStale = false,
|
||||
generationFailed = false,
|
||||
generationFailureClass = null,
|
||||
omittedReason = null,
|
||||
@@ -482,7 +465,7 @@ export function DailyBriefing({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(updatedAt || noProviderStale || generationFailed) && (
|
||||
{(updatedAt || generationFailed) && (
|
||||
<div className="border-border/60 space-y-1.5 border-t pt-3">
|
||||
{updatedAt && (
|
||||
<p
|
||||
@@ -498,33 +481,12 @@ export function DailyBriefing({
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{/* v1.18.9 (#4) — a stale briefing that can never refresh
|
||||
because no AI provider is connected. State that plainly
|
||||
and point at Settings → AI, so the days-old read is
|
||||
understood as held, not presented as current. */}
|
||||
{noProviderStale && (
|
||||
<p
|
||||
data-slot="daily-briefing-stale-no-provider"
|
||||
className="text-muted-foreground flex flex-wrap items-center justify-end gap-x-1.5 gap-y-1 text-right text-xs"
|
||||
>
|
||||
<span>
|
||||
{t("insights.dailyBriefing.staleNoProviderHint")}
|
||||
</span>
|
||||
<Link
|
||||
href="/settings/ai"
|
||||
data-slot="daily-briefing-stale-no-provider-link"
|
||||
className="text-foreground/80 hover:text-foreground underline underline-offset-2"
|
||||
>
|
||||
{t("insights.dailyBriefing.noProviderAction")}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
{/* v1.25 — the held briefing is shown, but the last refresh
|
||||
attempt failed (provider timeout / error). State it plainly
|
||||
and offer a retry so the staleness reads as held, not
|
||||
current. Suppressed when no provider is configured (that
|
||||
hint owns the footer) — a retry would be futile there. */}
|
||||
{generationFailed && !noProviderStale && (
|
||||
current. The card only mounts while the briefing
|
||||
capability is available, so a provider exists. */}
|
||||
{generationFailed && (
|
||||
<p
|
||||
data-slot="daily-briefing-refresh-failed"
|
||||
className="text-muted-foreground flex flex-wrap items-center justify-end gap-x-1.5 gap-y-1 text-right text-xs"
|
||||
@@ -546,29 +508,6 @@ export function DailyBriefing({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : noProvider ? (
|
||||
// v1.15.20 — no provider configured anywhere: a regenerate CTA
|
||||
// would 422 forever, so point at Settings → AI instead.
|
||||
<EmptyState
|
||||
data-slot="daily-briefing-no-provider"
|
||||
variant="plain"
|
||||
icon={<Sparkles className="size-5" />}
|
||||
title={t("insights.dailyBriefing.noProviderTitle")}
|
||||
description={t("insights.dailyBriefing.noProviderDescription")}
|
||||
action={
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
asChild
|
||||
data-slot="daily-briefing-no-provider-cta"
|
||||
>
|
||||
<Link href="/settings/ai">
|
||||
{t("insights.dailyBriefing.noProviderAction")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
data-slot="daily-briefing-empty"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Sparkles } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useDerivedMetric } from "./use-derived-metric";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import type {
|
||||
ReadinessValue,
|
||||
@@ -161,8 +162,21 @@ export function CompositeScoreAnatomy({
|
||||
</>
|
||||
);
|
||||
|
||||
if (query.isError) {
|
||||
// A failed read is not "insufficient data": the score may well exist.
|
||||
// Say it failed and offer a retry instead of an empty anatomy.
|
||||
return (
|
||||
<div className={className}>
|
||||
<QueryErrorRow
|
||||
slot="composite-anatomy-error"
|
||||
onRetry={() => void query.refetch()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
// Network/abort fallback — render the insufficient state honestly.
|
||||
// Settled without a payload — render the insufficient state honestly.
|
||||
return (
|
||||
<ScoreAnatomyView
|
||||
title={title}
|
||||
@@ -276,6 +290,9 @@ export function CompositeScoreAnatomy({
|
||||
icon={<Sparkles className="h-5 w-5" />}
|
||||
text={assessment.text}
|
||||
hasProvider
|
||||
// Composed from the numbers; the server swaps in model text only
|
||||
// while `statusText` is available, so the card shows either way.
|
||||
aiAuthored={false}
|
||||
updatedAt={assessment.updatedAt}
|
||||
// The outbound edge. Same shared opener + auto-send hand-off the
|
||||
// metric pages use, so the answer lands directly instead of only
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import {
|
||||
useTranslations,
|
||||
@@ -65,15 +64,6 @@ interface HeroStripProps {
|
||||
* Defaults to `new Date()`. Production callers omit this.
|
||||
*/
|
||||
now?: Date;
|
||||
/**
|
||||
* v1.18.9 (#4) — true when the hero subtitle is drawn from a cached
|
||||
* briefing that can never refresh because no AI provider is connected.
|
||||
* The meta row then appends a discreet "connect a provider" link beside
|
||||
* the "Generated <relative>" age, so the score-area context reads as
|
||||
* honestly held rather than current. Consistent with the DailyBriefing
|
||||
* footer hint below.
|
||||
*/
|
||||
noProviderStale?: boolean;
|
||||
/**
|
||||
* The Health Score report. Non-null paints the right column; null with
|
||||
* `scorePending` paints the reserve; null without it keeps the band
|
||||
@@ -117,7 +107,6 @@ export function HeroStrip({
|
||||
updatedAt,
|
||||
userName,
|
||||
now,
|
||||
noProviderStale = false,
|
||||
healthScore = null,
|
||||
scorePending = false,
|
||||
tension = null,
|
||||
@@ -222,30 +211,6 @@ export function HeroStrip({
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{/* v1.18.9 (#4) — the subtitle is a cached briefing that can
|
||||
never refresh (no AI provider). Append a discreet hint +
|
||||
Settings → AI link so the relative age above reads as
|
||||
intentionally held, consistent with the briefing card. */}
|
||||
{noProviderStale && (
|
||||
<>
|
||||
<span aria-hidden="true" className="opacity-50">
|
||||
·
|
||||
</span>
|
||||
<span
|
||||
data-slot="insights-hero-strip-stale-no-provider"
|
||||
className="inline-flex flex-wrap items-center gap-x-1.5"
|
||||
>
|
||||
<span>{t("insights.dailyBriefing.staleNoProviderHint")}</span>
|
||||
<Link
|
||||
href="/settings/ai"
|
||||
data-slot="insights-hero-strip-stale-no-provider-link"
|
||||
className="text-foreground/80 hover:text-foreground underline underline-offset-2"
|
||||
>
|
||||
{t("insights.dailyBriefing.noProviderAction")}
|
||||
</Link>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
@@ -14,7 +12,7 @@ import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import { ProseBlocks } from "@/components/insights/prose-blocks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import { AskCoachIconButton } from "@/components/insights/ask-coach-action";
|
||||
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
|
||||
import type { AssessmentStatus } from "@/lib/insights/status-shared";
|
||||
@@ -83,6 +81,14 @@ interface InsightStatusCardProps {
|
||||
* instead of only seeding the composer. Defaults to false.
|
||||
*/
|
||||
coachAutoSend?: boolean;
|
||||
/**
|
||||
* Whether the card's text is written by a model. True for every status
|
||||
* note, which only shows while the `statusText` capability is available.
|
||||
* The score anatomy passes false: its assessment is composed from the
|
||||
* numbers and only upgraded to model text by the server, so it shows
|
||||
* whatever the AI state.
|
||||
*/
|
||||
aiAuthored?: boolean;
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────
|
||||
@@ -99,13 +105,15 @@ export function InsightStatusCard({
|
||||
coachQuestion,
|
||||
coachScope,
|
||||
coachAutoSend,
|
||||
aiAuthored = true,
|
||||
}: InsightStatusCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const flags = useFeatureFlags();
|
||||
// v1.4.31 — operator can hide every per-metric status card
|
||||
// app-wide. The delta number stays; the LLM narration card is
|
||||
// suppressed in full so the layout collapses naturally.
|
||||
if (!flags.insightStatus) return null;
|
||||
const statusText = useAiCapability("statusText");
|
||||
// A status note is model-written text, so the card is hidden, never an
|
||||
// error or a "connect a provider" hint, while the `statusText` capability
|
||||
// is unavailable for any reason. The numbers around it stay; the layout
|
||||
// collapses naturally.
|
||||
if (aiAuthored && !statusText.available) return null;
|
||||
|
||||
const displayText =
|
||||
assessment?.kind === "generated" ||
|
||||
@@ -213,31 +221,6 @@ export function InsightStatusCard({
|
||||
);
|
||||
}
|
||||
|
||||
const isNoProvider =
|
||||
assessment?.kind === "no-provider" ||
|
||||
(!assessment && !hasProvider && !displayText);
|
||||
if (isNoProvider) {
|
||||
return (
|
||||
<Card className="gap-2 py-3 opacity-80 md:py-4">
|
||||
<CardHeader>
|
||||
<TileHeader icon={nodeIcon(icon)} title={title} />
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-1.5">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.noProviderConfigured")}
|
||||
</p>
|
||||
<Link
|
||||
href="/settings/ai"
|
||||
data-slot="insight-status-no-provider-cta"
|
||||
className="text-primary text-sm font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("insights.noProviderAction")}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!displayText) {
|
||||
return (
|
||||
<Card className="gap-2 py-3 md:py-4">
|
||||
|
||||
@@ -5,7 +5,8 @@ import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import { useSurfaceVisible } from "@/hooks/use-surface-visible";
|
||||
import { useWorkouts } from "@/hooks/use-workouts";
|
||||
import { InsightsTabStrip } from "@/components/insights/insights-tab-strip";
|
||||
import {
|
||||
@@ -52,26 +53,18 @@ interface ComprehensivePayload {
|
||||
|
||||
export function InsightsLayoutShell({ children }: { children: ReactNode }) {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
// v1.4.33 F18 — gate the advisor POST on the operator's assistant
|
||||
// feature flag. Pre-fix, every /insights mount fired POST
|
||||
// /api/insights/generate even when the operator had disabled the
|
||||
// briefing surface or the user had no AI provider configured. The
|
||||
// server returned 422 in that case and the regenerate button on the
|
||||
// tab strip rendered a non-functional spinner. Reading the flag
|
||||
// matrix off `/api/feature-flags` keeps the hot path one fetch
|
||||
// (shared `["feature-flags"]` cache, 60s staleTime) and skips the
|
||||
// advisor request entirely when the operator has the briefing gate
|
||||
// off. The fail-open default in `useFeatureFlags` means a network
|
||||
// hiccup still renders the advisor — the gate only takes effect when
|
||||
// the operator has explicitly turned the surface off.
|
||||
const flags = useFeatureFlags();
|
||||
// The briefing read and the regenerate button follow the `briefing`
|
||||
// capability on `/api/auth/me`: the server resolves the operator's switch,
|
||||
// the person's AI analysis opt-out, provider presence and consent into one
|
||||
// answer, and the strip offers regenerate only while that answer is yes.
|
||||
// Unavailable while `/me` loads, so nothing fires before the answer lands.
|
||||
const briefing = useAiCapability("briefing");
|
||||
// v1.16.4 — `mounted` keeps the hydration render in lockstep with the
|
||||
// SSR HTML: a late-hydrating boundary can see resolved query state on
|
||||
// its first render (auth + flags settled), which used to flip the
|
||||
// regenerate affordance on at hydration time and trip React #418.
|
||||
const mounted = useMounted();
|
||||
const advisorEnabled =
|
||||
mounted && isAuthenticated && flags.enabled && flags.briefing;
|
||||
const advisorEnabled = mounted && isAuthenticated && briefing.available;
|
||||
|
||||
const advisor = useInsightsAdvisorQuery(advisorEnabled);
|
||||
|
||||
@@ -104,7 +97,10 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
|
||||
// least one canonical row in the list-endpoint response. The
|
||||
// single-row probe shares its cache slot with the list page +
|
||||
// dashboard tile so navigation between surfaces is a free cache hit.
|
||||
const workoutsProbe = useWorkouts({ limit: 1 });
|
||||
// Only while the Workouts module shows: a switched-off module is never
|
||||
// probed (its pill is hidden by the surface map regardless).
|
||||
const workoutsVisible = useSurfaceVisible("insights-page:workouts");
|
||||
const workoutsProbe = useWorkouts({ limit: 1, enabled: workoutsVisible });
|
||||
|
||||
// v1.29 — nutrients gate. `nutrients` is opt-in (default off), unlike
|
||||
// the always-on metric clusters, so the pill's floor is the module
|
||||
|
||||
@@ -82,7 +82,8 @@ export interface InsightsTabStripProps {
|
||||
* v1.15.18 — the outcome of the last settled regenerate. The falling-edge
|
||||
* toast fires "refreshed" ONLY when this is `"fresh"`; a slow generation the
|
||||
* client gave up on (`"timeout"`) shows a "still working" hint instead of a
|
||||
* misleading success, and a missing provider (`"no-provider"`) stays silent
|
||||
* misleading success, and a missing provider (`"no-provider"`) or a refused
|
||||
* briefing (`"unavailable"`) stays silent
|
||||
* (the surface already shows the connect-AI empty state). v1.15.20 —
|
||||
* `"rate-limited"` (quota exhausted) and `"empty"` (transient 503) show
|
||||
* their own honest hints instead of the success toast. Absent (legacy
|
||||
@@ -94,6 +95,7 @@ export interface InsightsTabStripProps {
|
||||
| "rate-limited"
|
||||
| "timeout"
|
||||
| "no-provider"
|
||||
| "unavailable"
|
||||
| "settle-failed"
|
||||
| null;
|
||||
/**
|
||||
@@ -649,6 +651,7 @@ export function regenerateToastKind(
|
||||
case "settle-failed":
|
||||
return { kind: "error", messageKey: "insights.regenerateSettleFailed" };
|
||||
case "no-provider":
|
||||
case "unavailable":
|
||||
return null;
|
||||
default:
|
||||
return { kind: "success", messageKey: "insights.regenerateSuccess" };
|
||||
|
||||
@@ -9,8 +9,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useDisableCoach } from "@/hooks/use-disable-coach";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
@@ -78,8 +77,9 @@ export function LayoutCoachFab() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const launch = useCoachLaunch();
|
||||
const flags = useFeatureFlags();
|
||||
const disableCoach = useDisableCoach();
|
||||
// One answer for every Coach entry point: the `coach` capability on
|
||||
// `/api/auth/me` (operator switch, Hide Coach, provider, consent).
|
||||
const coach = useAiCapability("coach");
|
||||
|
||||
// Local "seen on this device" stamp — the nudge timestamp the user
|
||||
// last dismissed by opening the chat. Lazy initialiser per the
|
||||
@@ -93,7 +93,7 @@ export function LayoutCoachFab() {
|
||||
}
|
||||
});
|
||||
|
||||
const coachAvailable = !!launch && flags.coach && !disableCoach;
|
||||
const coachAvailable = !!launch && coach.available;
|
||||
const onCoachPage = pathname?.startsWith("/coach") ?? false;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useDisableCoach } from "@/hooks/use-disable-coach";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
|
||||
/**
|
||||
* v1.4.31 — defer the Coach drawer subtree behind `next/dynamic` so
|
||||
@@ -36,17 +35,13 @@ const CoachDrawer = dynamic(
|
||||
*/
|
||||
export function LayoutCoachMount() {
|
||||
const launch = useCoachLaunch();
|
||||
const flags = useFeatureFlags();
|
||||
const disableCoach = useDisableCoach();
|
||||
const coach = useAiCapability("coach");
|
||||
if (!launch) return null;
|
||||
// v1.4.31 — operator can hide the Coach drawer app-wide; suppress
|
||||
// the entire SSE mount when the flag is off.
|
||||
if (!flags.coach) return null;
|
||||
// v1.4.47 W3 — per-user opt-out short-circuits the drawer subtree
|
||||
// the same way the operator's master flag does. Keeps the SSE
|
||||
// chat reader + portal scaffolding out of the bundle for users
|
||||
// who never want the Coach mounted.
|
||||
if (disableCoach) return null;
|
||||
// The drawer subtree (SSE chat reader, portal scaffolding) mounts only
|
||||
// while the `coach` capability is available: the operator's switch, the
|
||||
// person's Hide Coach, a missing provider and missing consent all keep it
|
||||
// out, so nothing offers a Coach that cannot answer.
|
||||
if (!coach.available) return null;
|
||||
return (
|
||||
<CoachDrawer
|
||||
open={launch.open}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { TrendingUp } from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
@@ -43,16 +44,15 @@ export function MoodWhatStandsOut({
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.insightsCorrelations(),
|
||||
queryFn: async () => {
|
||||
// 403 = operator disabled the surface; any rejection (ApiError)
|
||||
// degrades to the card's isError → render-nothing path.
|
||||
// Correlations are statistics, never gated on AI; a rejection is a
|
||||
// real failure and renders the error row with a retry.
|
||||
return apiGet<CorrelationDiscoveryResponse>("/api/insights/correlations");
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60_000,
|
||||
// The surface is optional — don't retry a deliberate 403 into noise.
|
||||
retry: false,
|
||||
});
|
||||
|
||||
@@ -79,13 +79,11 @@ export function MoodWhatStandsOut({
|
||||
/>
|
||||
) : null}
|
||||
{isError ? (
|
||||
<p
|
||||
data-slot="mood-discovery-error"
|
||||
role="alert"
|
||||
className="text-destructive text-sm"
|
||||
>
|
||||
{t("insights.pattern.loadError")}
|
||||
</p>
|
||||
<QueryErrorRow
|
||||
slot="mood-discovery-error"
|
||||
message={t("insights.pattern.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
) : null}
|
||||
{!isLoading && !isError && data && !hasDiscovered ? (
|
||||
<p
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { Component, type ReactNode } from "react";
|
||||
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* One Insights overview section, fenced.
|
||||
*
|
||||
* The overview is a stack of independent sections, each with its own read.
|
||||
* A section that fails to load already shows its own error row; this fence
|
||||
* covers the other failure, a section that throws while rendering (a payload
|
||||
* shape it did not expect, a chunk that no longer exists after a deploy).
|
||||
* Without it the throw reaches the route boundary and replaces the whole
|
||||
* page, so one broken card takes the hero, the scores and every other
|
||||
* section down with it. Inside the fence the failed section becomes one row
|
||||
* with a retry, and the rest of the page stays.
|
||||
*
|
||||
* Retry clears the fence and renders the section again; a section whose
|
||||
* failure was transient comes back, one whose failure is permanent shows the
|
||||
* row again.
|
||||
*/
|
||||
interface FenceProps {
|
||||
sectionId: string;
|
||||
message: string;
|
||||
retryLabel: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface FenceState {
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
class SectionFence extends Component<FenceProps, FenceState> {
|
||||
state: FenceState = { failed: false };
|
||||
|
||||
static getDerivedStateFromError(): FenceState {
|
||||
return { failed: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error): void {
|
||||
// Same hand-off the chart boundary uses: the section degrades quietly,
|
||||
// the failure still reaches the client error tracker.
|
||||
if (typeof window !== "undefined") {
|
||||
const g = window as typeof window & {
|
||||
__healthlog_onError?: (err: Error) => void;
|
||||
};
|
||||
g.__healthlog_onError?.(error);
|
||||
}
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (!this.state.failed) return this.props.children;
|
||||
return (
|
||||
<section
|
||||
data-slot="insights-overview-section-error"
|
||||
data-section={this.props.sectionId}
|
||||
>
|
||||
<QueryErrorRow
|
||||
message={this.props.message}
|
||||
retryLabel={this.props.retryLabel}
|
||||
onRetry={() => this.setState({ failed: false })}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function OverviewSectionBoundary({
|
||||
sectionId,
|
||||
children,
|
||||
}: {
|
||||
sectionId: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<SectionFence
|
||||
sectionId={sectionId}
|
||||
message={t("insights.sectionLoadError")}
|
||||
retryLabel={t("common.retry")}
|
||||
>
|
||||
{children}
|
||||
</SectionFence>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { InsightSectionCard } from "./insight-section-card";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
|
||||
/**
|
||||
* v1.10.0 — device-flagged event awareness surface (categorical events,
|
||||
@@ -89,7 +90,7 @@ export function RhythmEventsCard({
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.insightsRhythmEvents(),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
@@ -103,6 +104,28 @@ export function RhythmEventsCard({
|
||||
enabled: enabled && isAuthenticated,
|
||||
});
|
||||
|
||||
// A failed read is said, never swallowed: this card carries health alerts,
|
||||
// and silently rendering nothing on an error would hide them.
|
||||
if (isError && !data) {
|
||||
return (
|
||||
<section
|
||||
data-slot="rhythm-events-section"
|
||||
aria-label={t("insights.rhythmEvents.sectionTitle")}
|
||||
className={cn("space-y-3", className)}
|
||||
>
|
||||
<SectionHeading
|
||||
icon={Activity}
|
||||
title={t("insights.rhythmEvents.sectionTitle")}
|
||||
/>
|
||||
<QueryErrorRow
|
||||
slot="rhythm-events-error"
|
||||
message={t("insights.rhythmEvents.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Data-availability gate — never paint an empty / alarming card.
|
||||
if (isLoading || !data || !data.hasEvents) return null;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
} from "@/lib/ai/schema";
|
||||
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import type { BriefingFailureClass } from "@/lib/insights/briefing-failure-marker";
|
||||
|
||||
/**
|
||||
@@ -245,9 +246,21 @@ export function nextAdvisorPollInterval(
|
||||
* regenerate quota is exhausted, which deserves a "try again later"
|
||||
* hint rather than the success toast the old lump produced. `empty` now
|
||||
* means only the transient 503 surface (provider chain unavailable).
|
||||
*
|
||||
* `unavailable` (403) is the server refusing the briefing capability: the
|
||||
* operator's switch, the person's opt-out, or a consent the chain needs. The
|
||||
* web only asks while `/api/auth/me` says the capability is available, so this
|
||||
* is the backstop for the moment between a switch flipping and `/me`
|
||||
* refreshing. It settles like the other expected refusals instead of
|
||||
* throwing, so the briefing card never turns into an error.
|
||||
*/
|
||||
export type AdvisorFetchOutcome =
|
||||
"fresh" | "empty" | "rate-limited" | "timeout" | "no-provider";
|
||||
| "fresh"
|
||||
| "empty"
|
||||
| "rate-limited"
|
||||
| "timeout"
|
||||
| "no-provider"
|
||||
| "unavailable";
|
||||
|
||||
interface AdvisorFetchResult {
|
||||
payload: InsightAdvisorPayload | null;
|
||||
@@ -316,6 +329,9 @@ export async function fetchAdvisor(
|
||||
if (res.status === 503) {
|
||||
return { payload: null, outcome: "empty" };
|
||||
}
|
||||
if (res.status === 403) {
|
||||
return { payload: null, outcome: "unavailable" };
|
||||
}
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const json = await res.json();
|
||||
@@ -384,24 +400,6 @@ export interface UseInsightsAdvisorResult {
|
||||
* assessment is still being prepared; NO toast fires until this falls.
|
||||
*/
|
||||
regenerateSettling: boolean;
|
||||
/**
|
||||
* v1.15.20 — the outcome of the last settled READ. Lets surfaces
|
||||
* distinguish "no briefing yet, a generate could help" (`empty` /
|
||||
* `timeout`) from "no provider configured, generating is futile"
|
||||
* (`no-provider`) and render a connect-AI hint instead of a dead
|
||||
* regenerate CTA.
|
||||
*/
|
||||
readOutcome: AdvisorFetchOutcome | null;
|
||||
/**
|
||||
* v1.18.9 (#4) — false when the GET reported no usable AI provider. The
|
||||
* read path still serves the last cached briefing (no provider is
|
||||
* needed to read the cache), so this is the ONLY honest signal that a
|
||||
* shown-but-stale briefing can never refresh. Surfaces pair it with the
|
||||
* relative-age line to add a discreet connect-provider hint. Defaults
|
||||
* true (no hint) when the field is absent — a pre-field cached payload
|
||||
* or an unsettled query.
|
||||
*/
|
||||
hasProvider: boolean;
|
||||
/**
|
||||
* v1.25 — true when the GET reported the last generation attempt failed.
|
||||
* Pairs a shown-but-held briefing with a discreet "couldn't refresh" hint,
|
||||
@@ -427,10 +425,17 @@ export interface UseInsightsAdvisorResult {
|
||||
/**
|
||||
* Read-only consumer for the advisor payload. Use this on surfaces that
|
||||
* just want to render the cached insight (e.g. dashboard preview).
|
||||
*
|
||||
* The read only fires while the `briefing` capability on `/api/auth/me` is
|
||||
* available, whatever the caller passes: every consumer (the overview, the
|
||||
* tab strip) gets the same gate, and none can re-enable the read behind the
|
||||
* other's back. While `/me` loads the capability reads unavailable, so the
|
||||
* first paint never fires a request only to have it refused.
|
||||
*/
|
||||
export function useInsightsAdvisorQuery(
|
||||
enabled: boolean,
|
||||
): UseInsightsAdvisorResult {
|
||||
const briefing = useAiCapability("briefing");
|
||||
const queryClient = useQueryClient();
|
||||
// Refs #786 — settling state for a timed-out force regenerate (see the
|
||||
// contract comment above `AdvisorSettleState`). `settleOutcome` carries the
|
||||
@@ -445,7 +450,7 @@ export function useInsightsAdvisorQuery(
|
||||
// outcome) so the read path can surface WHY a payload is missing
|
||||
// (`no-provider` → connect-AI hint instead of a dead regenerate CTA).
|
||||
queryFn: () => fetchAdvisor(),
|
||||
enabled,
|
||||
enabled: enabled && briefing.available,
|
||||
// 24h cache window matches the server-side `insightsCachedAt` TTL.
|
||||
staleTime: 60 * 60 * 1000,
|
||||
retry: false,
|
||||
@@ -577,11 +582,6 @@ export function useInsightsAdvisorQuery(
|
||||
// outcome ("timeout" while settling is an interim state, not a result).
|
||||
regenerateOutcome: settleOutcome ?? mutation.data?.outcome ?? null,
|
||||
regenerateSettling: settling !== null,
|
||||
readOutcome: query.data?.outcome ?? null,
|
||||
// Absent field (pre-v1.18.9 cached payload, or query still settling) →
|
||||
// assume a provider is present so a transient unknown never flashes a
|
||||
// false "no provider" hint.
|
||||
hasProvider: query.data?.payload?.hasProvider ?? true,
|
||||
// Absent (pre-field cached payload or unsettled query) → not failed, so a
|
||||
// transient unknown never flashes a false "couldn't refresh" hint.
|
||||
generationFailed: query.data?.payload?.generationFailed ?? false,
|
||||
|
||||
@@ -60,9 +60,10 @@ export interface WorkoutDetailDayContextProps {
|
||||
* undated and named for what their targets actually deliver — trends and
|
||||
* history, not "that day".
|
||||
*
|
||||
* Module gating: the intraday tile mounts only when `insights` is on and
|
||||
* the sleep row only when `sleep` is on, so a switched-off module never
|
||||
* paints as a failed request. Mood has no module gate, so the section
|
||||
* Module gating: the sleep row mounts only when `sleep` is on, so a
|
||||
* switched-off module never paints as a failed request. The intraday pulse
|
||||
* tile is heart-rate data and follows no module (the "AI analysis" switch
|
||||
* only governs model-written text). Mood has no module gate, so the section
|
||||
* always carries at least one part and the heading is never an orphan.
|
||||
*/
|
||||
export function WorkoutDetailDayContext({
|
||||
@@ -71,7 +72,6 @@ export function WorkoutDetailDayContext({
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const insightsEnabled = useModuleEnabled("insights");
|
||||
const sleepEnabled = useModuleEnabled("sleep");
|
||||
|
||||
const sleep = useSleepNight(workout.dayKey, isAuthenticated && sleepEnabled);
|
||||
@@ -96,12 +96,10 @@ export function WorkoutDetailDayContext({
|
||||
}
|
||||
/>
|
||||
|
||||
{insightsEnabled ? (
|
||||
<IntradayPulseChart
|
||||
userTimezone={user?.timezone}
|
||||
initialDateKey={workout.dayKey}
|
||||
/>
|
||||
) : null}
|
||||
<IntradayPulseChart
|
||||
userTimezone={user?.timezone}
|
||||
initialDateKey={workout.dayKey}
|
||||
/>
|
||||
|
||||
<Card className="gap-2 py-3 md:py-4" data-slot="workout-detail-day-card">
|
||||
<CardContent>
|
||||
|
||||
@@ -17,6 +17,15 @@ import type {
|
||||
* the strip, the chart placeholder, and the assessment card all paint.
|
||||
*/
|
||||
|
||||
// AI surfaces paint only while their capability is available; these tests
|
||||
// cover the painted surface, so every capability reads available here.
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: () => ({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
apiGet: () => new Promise(() => {}),
|
||||
apiDelete: vi.fn(),
|
||||
@@ -31,10 +40,6 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ isAuthenticated: true, user: { id: "u1" } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-feature-flags", () => ({
|
||||
useFeatureFlags: () => ({ insightStatus: true }),
|
||||
}));
|
||||
|
||||
import { LabBiomarkerDetail } from "../lab-biomarker-detail";
|
||||
|
||||
const BIOMARKER_ID = "bm-1";
|
||||
|
||||
@@ -10,7 +10,8 @@ describe("shouldProbeOcrCapability", () => {
|
||||
labsEnabled: true,
|
||||
mounted: true,
|
||||
ownRecord: true,
|
||||
};
|
||||
labsOcr: { available: true, reason: null, onDeviceAllowed: true },
|
||||
} as const;
|
||||
|
||||
expect(shouldProbeOcrCapability(ownerControl)).toBe(true);
|
||||
expect(
|
||||
@@ -26,4 +27,36 @@ describe("shouldProbeOcrCapability", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("follows the labsOcr capability: probes when available or when only consent is missing", () => {
|
||||
const base = {
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
labsEnabled: true,
|
||||
mounted: true,
|
||||
ownRecord: true,
|
||||
};
|
||||
const state = (reason: string | null) =>
|
||||
({
|
||||
available: reason === null,
|
||||
reason,
|
||||
onDeviceAllowed: false,
|
||||
}) as Parameters<typeof shouldProbeOcrCapability>[0]["labsOcr"];
|
||||
|
||||
// Consent is asked for inside the scan dialog, so the scan stays offered.
|
||||
expect(
|
||||
shouldProbeOcrCapability({ ...base, labsOcr: state("consent_required") }),
|
||||
).toBe(true);
|
||||
for (const reason of [
|
||||
"operator_disabled",
|
||||
"no_provider",
|
||||
"module_disabled",
|
||||
"not_permitted_for_record",
|
||||
"check_failed",
|
||||
]) {
|
||||
expect(
|
||||
shouldProbeOcrCapability({ ...base, labsOcr: state(reason) }),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,13 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
import { handleFilePickerChange, OcrReviewDialog } from "../ocr-review-dialog";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
|
||||
import {
|
||||
extractErrorMessage,
|
||||
handleFilePickerChange,
|
||||
OcrReviewDialog,
|
||||
} from "../ocr-review-dialog";
|
||||
|
||||
const hookState = vi.hoisted(() => ({ extractPending: false }));
|
||||
|
||||
@@ -44,13 +50,25 @@ vi.mock("../use-ocr-extract", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-grant-document-reading-consent", () => ({
|
||||
useGrantDocumentReadingConsent: () => ({
|
||||
isPending: false,
|
||||
isError: false,
|
||||
mutate: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
function render(
|
||||
locale: "en" | "de" = "en",
|
||||
options: { mode?: "vision" | "text"; pdfSupported?: boolean } = {},
|
||||
options: {
|
||||
mode?: "vision" | "text";
|
||||
pdfSupported?: boolean;
|
||||
consentRequired?: boolean;
|
||||
} = {},
|
||||
): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
@@ -59,6 +77,7 @@ function render(
|
||||
onOpenChange={() => {}}
|
||||
mode={options.mode ?? "vision"}
|
||||
pdfSupported={options.pdfSupported ?? true}
|
||||
consentRequired={options.consentRequired}
|
||||
onCommitted={() => {}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
@@ -138,3 +157,47 @@ describe("<OcrReviewDialog> file picker", () => {
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<OcrReviewDialog> document-reading consent", () => {
|
||||
it("asks for the reading consent in place of the picker while it is missing", () => {
|
||||
const html = render("en", { consentRequired: true });
|
||||
expect(html).toContain('data-slot="document-reading-consent"');
|
||||
expect(html).not.toMatch(/<input[^>]*type="file"/);
|
||||
});
|
||||
|
||||
it("shows the picker once consent is in place", () => {
|
||||
const html = render("en", { consentRequired: false });
|
||||
expect(html).not.toContain('data-slot="document-reading-consent"');
|
||||
expect(html).toMatch(/<input[^>]*type="file"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractErrorMessage", () => {
|
||||
const t = (key: string) => key;
|
||||
const refusal = (errorCode: string, status = 403) =>
|
||||
new ApiError("refused", status, { errorCode });
|
||||
|
||||
it("reads the refusal by its code, not by the status", () => {
|
||||
expect(extractErrorMessage(refusal("consent.ai.required"), t)).toBe(
|
||||
"labs.ocr.consentRequired",
|
||||
);
|
||||
expect(
|
||||
extractErrorMessage(refusal("assistant.disabled.documentAi"), t),
|
||||
).toBe("labs.ocr.aiUnavailable");
|
||||
expect(extractErrorMessage(refusal("ai.record.notPermitted"), t)).toBe(
|
||||
"labs.ocr.aiUnavailable",
|
||||
);
|
||||
expect(extractErrorMessage(refusal("module.disabled"), t)).toBe(
|
||||
"labs.ocr.aiUnavailable",
|
||||
);
|
||||
expect(extractErrorMessage(refusal("ai.provider.none", 422), t)).toBe(
|
||||
"labs.ocr.providerUnsupported",
|
||||
);
|
||||
});
|
||||
|
||||
it("never calls an unrelated 403 a missing consent", () => {
|
||||
expect(extractErrorMessage(new ApiError("forbidden", 403), t)).toBe(
|
||||
"labs.ocr.extractFailed",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@
|
||||
* confirms. Reuses the labs design language via `ResponsiveSheet` + the labs
|
||||
* row editor.
|
||||
*/
|
||||
import { DocumentReadingConsentPrompt } from "@/components/ai/document-reading-consent-prompt";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
|
||||
import { Loader2, ScanLine, Upload } from "lucide-react";
|
||||
@@ -81,8 +82,15 @@ function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null {
|
||||
};
|
||||
}
|
||||
|
||||
/** Translate an extract failure into a friendly, error-code-aware message. */
|
||||
function extractErrorMessage(err: unknown, t: (key: string) => string): string {
|
||||
/**
|
||||
* Translate an extract failure into a friendly, error-code-aware message.
|
||||
* Refusals are read by `meta.errorCode`: a 403 is a missing consent only when
|
||||
* the code says so, never by its status alone.
|
||||
*/
|
||||
export function extractErrorMessage(
|
||||
err: unknown,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
if (err instanceof ApiError) {
|
||||
const code =
|
||||
typeof err.meta?.errorCode === "string" ? err.meta.errorCode : null;
|
||||
@@ -99,10 +107,23 @@ function extractErrorMessage(err: unknown, t: (key: string) => string): string {
|
||||
return t("labs.ocr.fileType");
|
||||
case "labs.ocr.pdfNeedsAnthropic":
|
||||
return t("labs.ocr.pdfNeedsAnthropic");
|
||||
case "consent.ai.required":
|
||||
return t("labs.ocr.consentRequired");
|
||||
case "ai.provider.none":
|
||||
return t("labs.ocr.providerUnsupported");
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (err.status === 403) return t("labs.ocr.consentRequired");
|
||||
// The operator's switch, the module, somebody else's record: scanning is
|
||||
// not available here right now, whatever the exact layer.
|
||||
if (
|
||||
code !== null &&
|
||||
(code.startsWith("assistant.disabled.") ||
|
||||
code === "ai.record.notPermitted" ||
|
||||
code === "module.disabled")
|
||||
) {
|
||||
return t("labs.ocr.aiUnavailable");
|
||||
}
|
||||
}
|
||||
return t("labs.ocr.extractFailed");
|
||||
}
|
||||
@@ -112,6 +133,7 @@ export function OcrReviewDialog({
|
||||
onOpenChange,
|
||||
mode,
|
||||
pdfSupported,
|
||||
consentRequired = false,
|
||||
onCommitted,
|
||||
}: {
|
||||
open: boolean;
|
||||
@@ -119,6 +141,12 @@ export function OcrReviewDialog({
|
||||
/** Native vision vs in-browser local OCR. Text mode is image-only. */
|
||||
mode: OcrMode;
|
||||
pdfSupported: boolean;
|
||||
/**
|
||||
* The `labsOcr` capability is missing only the document-reading consent.
|
||||
* The pick step asks for it instead of offering the file picker; once it is
|
||||
* granted `/api/auth/me` refreshes and the picker appears.
|
||||
*/
|
||||
consentRequired?: boolean;
|
||||
onCommitted: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
@@ -270,7 +298,9 @@ export function OcrReviewDialog({
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{stage === "pick" ? (
|
||||
{stage === "pick" && consentRequired ? (
|
||||
<DocumentReadingConsentPrompt />
|
||||
) : stage === "pick" ? (
|
||||
<div className="relative py-2">
|
||||
<input
|
||||
id={pickerId}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* - `useOcrCommit()` — writes the user-confirmed rows and invalidates the
|
||||
* labs + biomarker query keys.
|
||||
*/
|
||||
import type { AiCapabilityState } from "@/lib/ai/capabilities/types";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
@@ -41,13 +42,22 @@ export interface OcrCommitResult extends OcrCommitResponseDto {
|
||||
inserted: LabResultDto[];
|
||||
}
|
||||
|
||||
/** The capability route resolves the caller's own provider configuration. */
|
||||
/**
|
||||
* The capability route resolves the caller's own provider configuration.
|
||||
*
|
||||
* It only runs while the `labsOcr` capability on `/api/auth/me` could let a
|
||||
* scan happen: available, or missing nothing but the document-reading
|
||||
* consent, which the scan dialog asks for in place. Any other reason (the
|
||||
* operator's switch, no provider, the module, somebody else's record) means
|
||||
* no scan is offered, so there is nothing to probe.
|
||||
*/
|
||||
export function shouldProbeOcrCapability({
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
labsEnabled,
|
||||
mounted,
|
||||
ownRecord,
|
||||
labsOcr,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
@@ -55,8 +65,17 @@ export function shouldProbeOcrCapability({
|
||||
mounted: boolean;
|
||||
/** In the caller's own record; the probe and the commit resolve the caller, never a grant. */
|
||||
ownRecord: boolean;
|
||||
labsOcr: AiCapabilityState;
|
||||
}): boolean {
|
||||
return isAuthenticated && !isLoading && labsEnabled && mounted && ownRecord;
|
||||
const aiOffered = labsOcr.available || labsOcr.reason === "consent_required";
|
||||
return (
|
||||
isAuthenticated &&
|
||||
!isLoading &&
|
||||
labsEnabled &&
|
||||
mounted &&
|
||||
ownRecord &&
|
||||
aiOffered
|
||||
);
|
||||
}
|
||||
|
||||
/** Capability probe — refetched when the scan dialog opens. */
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { isSurfaceVisible } from "@/lib/modules/surface";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useNavModules } from "@/hooks/use-nav-modules";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import {
|
||||
@@ -107,15 +108,18 @@ export function BottomNav() {
|
||||
// feature destination that isn't a primary slot, plus the shared utility
|
||||
// tail. Cycle is gated by the same flag the sidebar uses, so the two
|
||||
// surfaces gate identically and cannot drift.
|
||||
// The Coach entry follows the `coach` AI capability (a Coach that cannot
|
||||
// answer is not offered); the sidebar reads the same map.
|
||||
const navModules = useNavModules();
|
||||
const moreHub = useMemo<ReadonlyArray<NavLink>>(
|
||||
() =>
|
||||
mobileMoreHubDestinations({
|
||||
modules: user?.modules,
|
||||
modules: navModules,
|
||||
mounted,
|
||||
sharedRecord,
|
||||
sections,
|
||||
}),
|
||||
[user?.modules, mounted, sharedRecord, sections],
|
||||
[navModules, mounted, sharedRecord, sections],
|
||||
);
|
||||
|
||||
// Every slot but Home asks two things: does sharing cover it, and does its
|
||||
@@ -133,10 +137,10 @@ export function BottomNav() {
|
||||
// Insights drops out under a switch.
|
||||
(!sharedRecord || isDestinationInSharedRecord(item.href, sections)) &&
|
||||
mounted &&
|
||||
isSurfaceVisible(`nav:${item.href}`, user?.modules)
|
||||
isSurfaceVisible(`nav:${item.href}`, navModules)
|
||||
);
|
||||
},
|
||||
[user?.modules, mounted, sharedRecord, sections],
|
||||
[navModules, mounted, sharedRecord, sections],
|
||||
);
|
||||
|
||||
// Both fixed flanks run through the ONE slot filter, so a module-gated
|
||||
|
||||
@@ -29,6 +29,7 @@ import { cn } from "@/lib/utils";
|
||||
import { Logo } from "@/components/ui/logo";
|
||||
import { useAuth, useLogout } from "@/hooks/use-auth";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
import { useNavModules } from "@/hooks/use-nav-modules";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTheme } from "@/components/providers";
|
||||
@@ -320,10 +321,12 @@ export function SidebarNav() {
|
||||
// The Settings entry needs them: a record-content page is offered only when
|
||||
// the grant manages the section its forms write.
|
||||
const manageableDomains = user?.accountAccess?.active?.manageableDomains;
|
||||
// The Coach entry follows the `coach` AI capability, like every other
|
||||
// Coach entry point; the rest of the map is `modules` unchanged.
|
||||
const navModules = useNavModules();
|
||||
const visibleNavItems = useMemo(
|
||||
() =>
|
||||
visibleNavDestinations(user?.modules, mounted, sharedRecord, sections),
|
||||
[user?.modules, mounted, sharedRecord, sections],
|
||||
() => visibleNavDestinations(navModules, mounted, sharedRecord, sections),
|
||||
[navModules, mounted, sharedRecord, sections],
|
||||
);
|
||||
// Collapsed = the user's stored choice, or — with no stored choice — a
|
||||
// viewport default: tablet widths (md–lg, e.g. an iPad held upright)
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* The medication text reader says why it could not read, from the refusal's
|
||||
* code. Before, a spent daily budget (429 `coach.budget.exceeded`) showed the
|
||||
* rate-limit sentence, and a switched-off reader or a missing consent showed
|
||||
* "check your connection".
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { classifyExtractFailure } from "../natural-language-extractor";
|
||||
|
||||
const envelope = (errorCode?: string, error = "refused") => ({
|
||||
data: null,
|
||||
error,
|
||||
...(errorCode ? { meta: { errorCode } } : {}),
|
||||
});
|
||||
|
||||
describe("classifyExtractFailure", () => {
|
||||
it("reads the code before the status", () => {
|
||||
expect(
|
||||
classifyExtractFailure(429, envelope("coach.budget.exceeded")).kind,
|
||||
).toBe("budget");
|
||||
expect(classifyExtractFailure(429, envelope()).kind).toBe("rateLimit");
|
||||
});
|
||||
|
||||
it("names a missing provider, a missing consent and a switched-off reader", () => {
|
||||
expect(classifyExtractFailure(503, envelope("ai.provider.none")).kind).toBe(
|
||||
"noProvider",
|
||||
);
|
||||
expect(
|
||||
classifyExtractFailure(403, envelope("consent.ai.required")).kind,
|
||||
).toBe("consent");
|
||||
expect(
|
||||
classifyExtractFailure(403, envelope("assistant.disabled.documentAi"))
|
||||
.kind,
|
||||
).toBe("unavailable");
|
||||
expect(
|
||||
classifyExtractFailure(403, envelope("ai.record.notPermitted")).kind,
|
||||
).toBe("unavailable");
|
||||
});
|
||||
|
||||
it("keeps a bare 503 as no provider and anything else as a network failure", () => {
|
||||
expect(classifyExtractFailure(503, null).kind).toBe("noProvider");
|
||||
expect(classifyExtractFailure(500, envelope()).kind).toBe("network");
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,7 @@ import { Loader2, Sparkles } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { DocumentReadingConsentPrompt } from "@/components/ai/document-reading-consent-prompt";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -77,6 +78,12 @@ export interface NaturalLanguageExtractorProps {
|
||||
* point at a mock server. Defaults to the production route.
|
||||
*/
|
||||
endpoint?: string;
|
||||
/**
|
||||
* The `medicationExtract` capability is missing only the document-reading
|
||||
* consent. The sheet asks for it above the text field and holds the submit
|
||||
* until `/api/auth/me` reports it granted.
|
||||
*/
|
||||
consentRequired?: boolean;
|
||||
}
|
||||
|
||||
type DialogError =
|
||||
@@ -85,8 +92,43 @@ type DialogError =
|
||||
| { kind: "rateLimit" }
|
||||
| { kind: "noProvider" }
|
||||
| { kind: "budget" }
|
||||
| { kind: "consent" }
|
||||
| { kind: "unavailable" }
|
||||
| { kind: "network"; message?: string };
|
||||
|
||||
interface ExtractFailureBody {
|
||||
error?: string | null;
|
||||
meta?: { errorCode?: unknown } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a failed extract response. The refusal's `meta.errorCode` decides
|
||||
* first, because a status is shared by different reasons: a 429 is either
|
||||
* the per-minute limit or the spent daily budget, and a 403 is a missing
|
||||
* consent, the operator's switch or somebody else's record.
|
||||
*/
|
||||
export function classifyExtractFailure(
|
||||
status: number,
|
||||
body: ExtractFailureBody | null,
|
||||
): DialogError {
|
||||
const code =
|
||||
typeof body?.meta?.errorCode === "string" ? body.meta.errorCode : null;
|
||||
if (code === "coach.budget.exceeded") return { kind: "budget" };
|
||||
if (code === "ai.provider.none") return { kind: "noProvider" };
|
||||
if (code === "consent.ai.required") return { kind: "consent" };
|
||||
if (
|
||||
code !== null &&
|
||||
(code.startsWith("assistant.disabled.") ||
|
||||
code === "ai.record.notPermitted" ||
|
||||
code === "module.disabled")
|
||||
) {
|
||||
return { kind: "unavailable" };
|
||||
}
|
||||
if (status === 429) return { kind: "rateLimit" };
|
||||
if (status === 503) return { kind: "noProvider" };
|
||||
return { kind: "network", message: body?.error ?? undefined };
|
||||
}
|
||||
|
||||
const MAX_TEXT_LENGTH = 2000;
|
||||
|
||||
export function NaturalLanguageExtractor({
|
||||
@@ -95,6 +137,7 @@ export function NaturalLanguageExtractor({
|
||||
onPrefill,
|
||||
locale,
|
||||
endpoint = "/api/medications/extract",
|
||||
consentRequired = false,
|
||||
}: NaturalLanguageExtractorProps) {
|
||||
const { t } = useTranslations();
|
||||
const textareaId = useId();
|
||||
@@ -143,23 +186,11 @@ export function NaturalLanguageExtractor({
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (res.status === 429) {
|
||||
setError({ kind: "rateLimit" });
|
||||
return;
|
||||
}
|
||||
if (res.status === 503) {
|
||||
setError({ kind: "noProvider" });
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
if (body?.error && /budget/i.test(body.error)) {
|
||||
setError({ kind: "budget" });
|
||||
return;
|
||||
}
|
||||
setError({ kind: "network", message: body?.error });
|
||||
const body = (await res
|
||||
.json()
|
||||
.catch(() => null)) as ExtractFailureBody | null;
|
||||
setError(classifyExtractFailure(res.status, body));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -228,7 +259,7 @@ export function NaturalLanguageExtractor({
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={busy || overLimit}
|
||||
disabled={busy || overLimit || consentRequired}
|
||||
aria-busy={busy || undefined}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
@@ -248,6 +279,7 @@ export function NaturalLanguageExtractor({
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3" aria-busy={busy || undefined}>
|
||||
{consentRequired ? <DocumentReadingConsentPrompt /> : null}
|
||||
<div>
|
||||
<Label htmlFor={textareaId} className="sr-only">
|
||||
{t("medications.scheduling.naturalLanguage.title")}
|
||||
@@ -348,6 +380,10 @@ function resolveErrorMessage(
|
||||
return t("medications.scheduling.naturalLanguage.error.noProvider");
|
||||
case "budget":
|
||||
return t("medications.scheduling.naturalLanguage.error.budget");
|
||||
case "consent":
|
||||
return t("medications.scheduling.naturalLanguage.error.consent");
|
||||
case "unavailable":
|
||||
return t("medications.scheduling.naturalLanguage.error.unavailable");
|
||||
case "network":
|
||||
default:
|
||||
return t("medications.scheduling.naturalLanguage.error.network");
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Bell,
|
||||
@@ -127,6 +128,14 @@ function WizardDialogShell({
|
||||
navigateOnCreate = true,
|
||||
}: MedicationWizardDialogProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
// The "describe it in words" reader follows the `medicationExtract`
|
||||
// capability: offered while available, and while only the document-reading
|
||||
// consent is missing (the sheet asks for it). Otherwise the button is not
|
||||
// there at all, and the manual steps are the whole wizard.
|
||||
const medicationExtract = useAiCapability("medicationExtract");
|
||||
const extractOffered =
|
||||
medicationExtract.available ||
|
||||
medicationExtract.reason === "consent_required";
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -489,7 +498,7 @@ function WizardDialogShell({
|
||||
lastLabel={t("medications.wizard.nav.jumpLast")}
|
||||
srLabel={stepOf}
|
||||
/>
|
||||
{step === 1 && mode === "create" && (
|
||||
{step === 1 && mode === "create" && extractOffered && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -612,7 +621,7 @@ function WizardDialogShell({
|
||||
{/* Natural-language extractor — same dialog the v1.5.3 page mounted.
|
||||
Only available on the create path; edit flows already know
|
||||
what the medication looks like. */}
|
||||
{mode === "create" && (
|
||||
{mode === "create" && extractOffered && (
|
||||
<NaturalLanguageExtractor
|
||||
open={nlOpen}
|
||||
onClose={() => setNlOpen(false)}
|
||||
@@ -620,6 +629,7 @@ function WizardDialogShell({
|
||||
applyPartial(extractorToWizardPartial(partial));
|
||||
}}
|
||||
locale={locale}
|
||||
consentRequired={medicationExtract.reason === "consent_required"}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -44,6 +44,23 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// The AI capability map on `/api/auth/me`: everything available, a provider
|
||||
// configured, nothing switched off by the operator. The notices and the
|
||||
// setup hint above the provider card have their own suites.
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: () => ({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
}),
|
||||
useAiCapabilityMap: () => null,
|
||||
useAiProviderState: () => ({
|
||||
configured: true,
|
||||
managedBy: "user",
|
||||
canConfigure: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Stable mocks for every TanStack Query the section reads. The chain
|
||||
// fixture mirrors the default order (codex first) so the rows render in
|
||||
// a known order; insights settings + ai-provider GET return enough to
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* What the Coach stored stays readable and deletable while the Coach is off.
|
||||
*
|
||||
* Coach conversations and the facts the Coach remembered are the person's
|
||||
* own records, not caches. Before v1.39 Settings → Coach hid the memory card
|
||||
* together with the Coach's tuning as soon as the Coach was hidden, and with
|
||||
* the Coach module off the whole page was out of reach, so a person could
|
||||
* neither see nor erase what had been stored. Now:
|
||||
*
|
||||
* - Settings → Coach always shows the memory (facts and conversations),
|
||||
* whatever `disableCoach` says;
|
||||
* - Settings → AI shows the same cards through `<StoredCoachMemory>` while
|
||||
* Settings → Coach is not reachable, and only when rows exist.
|
||||
*/
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const authState = vi.hoisted(() => ({ disableCoach: true }));
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "u1",
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
disableCoach: authState.disableCoach,
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
// Past hydration: the section's own gates read the account only once
|
||||
// mounted, and a static render is otherwise the pre-hydration pass, where
|
||||
// every card shows and the test could not tell a gate from its absence.
|
||||
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/settings/coach",
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { CoachSection } from "../coach-section";
|
||||
import { StoredCoachMemory } from "../coach-memory-section";
|
||||
|
||||
const FACT = {
|
||||
id: "fact-1",
|
||||
category: "goal",
|
||||
text: "Wants to walk to work twice a week",
|
||||
confidence: 0.9,
|
||||
createdAt: "2026-09-01T08:00:00.000Z",
|
||||
};
|
||||
const CONVERSATION = {
|
||||
id: "conv-1",
|
||||
title: "Morning blood pressure",
|
||||
createdAt: "2026-09-01T08:00:00.000Z",
|
||||
updatedAt: "2026-09-01T08:05:00.000Z",
|
||||
messageCount: 4,
|
||||
fenced: false,
|
||||
attachments: [],
|
||||
};
|
||||
|
||||
function client(stored: { facts: unknown[]; conversations: unknown[] }) {
|
||||
const c = new QueryClient({
|
||||
defaultOptions: { queries: { retry: 0, staleTime: Infinity } },
|
||||
});
|
||||
c.setQueryData(queryKeys.coachFacts(), stored.facts);
|
||||
c.setQueryData(queryKeys.coachConversationHistory(""), {
|
||||
pages: [{ conversations: stored.conversations, nextCursor: null }],
|
||||
pageParams: [null],
|
||||
});
|
||||
return c;
|
||||
}
|
||||
|
||||
function render(
|
||||
node: React.ReactNode,
|
||||
stored: { facts: unknown[]; conversations: unknown[] },
|
||||
) {
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client(stored)}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Everything is seeded; a background refetch must not reach the network.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("{}", { status: 500 })),
|
||||
);
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("Settings → Coach keeps the Coach memory while the Coach is hidden", () => {
|
||||
it("shows the facts and the conversations with Hide Coach on", () => {
|
||||
authState.disableCoach = true;
|
||||
const html = render(<CoachSection />, {
|
||||
facts: [FACT],
|
||||
conversations: [CONVERSATION],
|
||||
});
|
||||
expect(html).toContain('data-testid="settings-coach-memory-card"');
|
||||
expect(html).toContain("Wants to walk to work twice a week");
|
||||
expect(html).toContain('data-testid="settings-coach-conversations-card"');
|
||||
expect(html).toContain("Morning blood pressure");
|
||||
// Deletable: the per-row controls are there.
|
||||
expect(html).toContain('data-slot="settings-coach-memory-forget"');
|
||||
expect(html).toContain('data-slot="settings-coach-conversation-delete"');
|
||||
// The Coach's tuning stays hidden with the Coach.
|
||||
expect(html).not.toContain('id="coach-nudge"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<StoredCoachMemory> (Settings → AI while the Coach page is out of reach)", () => {
|
||||
it("shows what is stored, readable and deletable", () => {
|
||||
const html = render(<StoredCoachMemory isAuthenticated />, {
|
||||
facts: [FACT],
|
||||
conversations: [CONVERSATION],
|
||||
});
|
||||
expect(html).toContain('data-testid="settings-coach-memory-card"');
|
||||
expect(html).toContain('data-testid="settings-coach-conversations-card"');
|
||||
expect(html).toContain('data-slot="settings-coach-conversation-open"');
|
||||
expect(html).toContain('data-slot="settings-coach-conversation-delete"');
|
||||
});
|
||||
|
||||
it("shows only the card that has rows", () => {
|
||||
const html = render(<StoredCoachMemory isAuthenticated />, {
|
||||
facts: [],
|
||||
conversations: [CONVERSATION],
|
||||
});
|
||||
expect(html).not.toContain('data-testid="settings-coach-memory-card"');
|
||||
expect(html).toContain('data-testid="settings-coach-conversations-card"');
|
||||
});
|
||||
|
||||
it("renders nothing for somebody the Coach never stored anything for", () => {
|
||||
const html = render(<StoredCoachMemory isAuthenticated />, {
|
||||
facts: [],
|
||||
conversations: [],
|
||||
});
|
||||
expect(html).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ vi.mock("next/navigation", () => ({
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: null, isLoading: false, refetch: vi.fn() }),
|
||||
useInfiniteQuery: () => ({ data: undefined, isLoading: false }),
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
|
||||
@@ -13,8 +13,13 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
user: { modules: modulesRef.value },
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/use-feature-flags", () => ({
|
||||
useFeatureFlags: () => ({ briefing: true }),
|
||||
const briefingRef: { available: boolean } = { available: true };
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: () => ({
|
||||
available: briefingRef.available,
|
||||
reason: briefingRef.available ? null : "user_disabled",
|
||||
onDeviceAllowed: false,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/use-insights-layout", () => ({
|
||||
useInsightsLayoutQuery: () => ({
|
||||
@@ -63,4 +68,12 @@ describe("<InsightsOverviewArrangeSection> module gate", () => {
|
||||
it("marks nothing for AI analysis off", () => {
|
||||
expect(gatedFor({ insights: false })).toEqual([]);
|
||||
});
|
||||
|
||||
it("marks only the daily briefing when the briefing is unavailable", () => {
|
||||
// The period review renders its composed narrative whatever the AI
|
||||
// state, so it stays a live row.
|
||||
briefingRef.available = false;
|
||||
expect(gatedFor({})).toEqual(["daily-briefing"]);
|
||||
briefingRef.available = true;
|
||||
});
|
||||
});
|
||||
@@ -63,21 +63,18 @@ vi.mock("@/hooks/use-auth", async () => {
|
||||
return { ...actual, useAuth: () => authSpy() };
|
||||
});
|
||||
|
||||
// The hub now renders a live coach switch, so it reads the operator assistant
|
||||
// flag matrix (`flags.coach`) to decide whether the operator killed Coach
|
||||
// server-wide. Mock the hook directly so the SSR pass doesn't need a
|
||||
// `<QueryClientProvider>`; default all-on.
|
||||
import type { AssistantFlagSet } from "@/hooks/use-feature-flags";
|
||||
const ALL_ON_FLAGS: AssistantFlagSet = {
|
||||
enabled: true,
|
||||
coach: true,
|
||||
briefing: true,
|
||||
insightStatus: true,
|
||||
documentAi: true,
|
||||
// The hub renders a live coach switch and reads the `coach` AI capability to
|
||||
// decide whether the operator switched the Coach off server-wide. Mock the
|
||||
// hook directly so the SSR pass doesn't need a `<QueryClientProvider>`.
|
||||
import type { AiCapabilityState } from "@/lib/ai/capabilities/types";
|
||||
const COACH_AVAILABLE: AiCapabilityState = {
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
};
|
||||
const flagsSpy = vi.fn<() => AssistantFlagSet>(() => ALL_ON_FLAGS);
|
||||
vi.mock("@/hooks/use-feature-flags", () => ({
|
||||
useFeatureFlags: () => flagsSpy(),
|
||||
const coachSpy = vi.fn<() => AiCapabilityState>(() => COACH_AVAILABLE);
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: () => coachSpy(),
|
||||
}));
|
||||
|
||||
function buildUser(
|
||||
@@ -133,7 +130,7 @@ beforeEach(() => {
|
||||
user: buildUser({}),
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
flagsSpy.mockImplementation(() => ALL_ON_FLAGS);
|
||||
coachSpy.mockImplementation(() => COACH_AVAILABLE);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -295,7 +292,11 @@ describe("<ModulesSection>", () => {
|
||||
|
||||
it("disables the switch + shows a hint when the operator killed Coach", () => {
|
||||
// Operator assistant master flag off → coach is unavailable server-wide.
|
||||
flagsSpy.mockImplementation(() => ({ ...ALL_ON_FLAGS, coach: false }));
|
||||
coachSpy.mockImplementation(() => ({
|
||||
available: false,
|
||||
reason: "operator_disabled",
|
||||
onDeviceAllowed: false,
|
||||
}));
|
||||
const html = render();
|
||||
const coachTag = html.match(
|
||||
/<button[^>]*id="module-toggle-coach"[^>]*>/,
|
||||
|
||||
@@ -45,6 +45,22 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
// The AI capability map on `/api/auth/me`: everything available, a provider
|
||||
// configured, nothing switched off by the operator.
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: () => ({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
}),
|
||||
useAiCapabilityMap: () => null,
|
||||
useAiProviderState: () => ({
|
||||
configured: true,
|
||||
managedBy: "user",
|
||||
canConfigure: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Stub the auth hook — sections check `isAuthenticated` to enable queries.
|
||||
// v1.16.4 — AccountSection gates its content on `useMounted()` so the
|
||||
// SSR pass and the hydration render agree (React #418 family). The
|
||||
|
||||
@@ -19,11 +19,15 @@
|
||||
*/
|
||||
|
||||
import { AiInsightsCard } from "@/components/settings/ai/ai-insights-card";
|
||||
import { OperatorOffNotice } from "@/components/settings/ai/operator-off-notice";
|
||||
import { StoredCoachMemory } from "@/components/settings/coach-memory-section";
|
||||
import { AiSetupHint } from "@/components/insights/ai-setup-hint";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { isSurfaceVisible } from "@/lib/modules/surface";
|
||||
|
||||
export function AiSection() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
// v1.16.6 — the auth query can resolve before this boundary
|
||||
// hydrates; every `disabled={!isAuthenticated}` binding in the child
|
||||
// cards would then disagree with the SSR HTML (React #418). Gate the
|
||||
@@ -36,5 +40,23 @@ export function AiSection() {
|
||||
// v1.18.6 (W9) — the visible heading + subtitle now come from the shared
|
||||
// `<SettingsSectionFrame>` in the route; this body is the single provider
|
||||
// card. (The "About me" context moved to Settings → Account in v1.18.1 D8.)
|
||||
return <AiInsightsCard isAuthenticated={authed} />;
|
||||
//
|
||||
// v1.39 — above the provider card: what the operator has switched off (so
|
||||
// nothing below reads as broken), and the one calm setup hint while no
|
||||
// provider is configured. Below it: what the Coach stored, while Settings →
|
||||
// Coach is not reachable, so it can always be read and deleted.
|
||||
const coachSettingsReachable = isSurfaceVisible(
|
||||
"settings:coach",
|
||||
user?.modules,
|
||||
);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<OperatorOffNotice />
|
||||
<AiSetupHint surface="settings" />
|
||||
<AiInsightsCard isAuthenticated={authed} />
|
||||
{authed && !coachSettingsReachable ? (
|
||||
<StoredCoachMemory isAuthenticated={authed} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -75,6 +75,35 @@ describe("AiConsentCard", () => {
|
||||
expect(html).toContain('data-slot="ai-consent-grant"');
|
||||
});
|
||||
|
||||
it("never reports a failed read as a withdrawal", () => {
|
||||
// A refused or failed read (the consent bucket's rate limit, a network
|
||||
// drop) says nothing about the decision. Reporting it as "Withdrawn" and
|
||||
// offering a grant would misstate the person's own choice.
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, retryOnMount: false } },
|
||||
});
|
||||
client
|
||||
.getQueryCache()
|
||||
.build(client, { queryKey: receiptKey })
|
||||
.setState({
|
||||
status: "error",
|
||||
error: new Error("read failed"),
|
||||
data: undefined,
|
||||
fetchStatus: "idle",
|
||||
});
|
||||
const html = renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<AiConsentCard isAuthenticated />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(html).not.toContain("Withdrawn.");
|
||||
expect(html).not.toContain('data-slot="ai-consent-grant"');
|
||||
expect(html).not.toContain('data-slot="ai-consent-withdraw"');
|
||||
expect(html).toContain('data-slot="ai-consent-load-error"');
|
||||
});
|
||||
|
||||
it("never shows the grant control while consent stands", () => {
|
||||
const html = renderWith({
|
||||
id: "rcpt-1",
|
||||
|
||||
@@ -15,9 +15,18 @@
|
||||
* without an active receipt, so every AI surface falls back to its
|
||||
* no-consent state on the next call. Granting posts an affirmative intent,
|
||||
* which is the consent act itself and therefore may supersede an earlier
|
||||
* revocation; the silent mount heal never does. Nothing already stored is
|
||||
* deleted by a withdrawal; that is the export/erase path in Data & Privacy,
|
||||
* and the copy says so rather than implying more than it does.
|
||||
* revocation; the silent mount heal never does.
|
||||
*
|
||||
* v1.39 — a withdrawal also deletes what the app can write again: the notes
|
||||
* a model wrote from the person's data (status notes, the daily briefing,
|
||||
* model-written period summaries, arrival lines, workout notes), in the same
|
||||
* step as the revoke. The person's own records stay: Coach conversations,
|
||||
* what the Coach remembered, plans and document summaries, readable and
|
||||
* deletable by them. The confirm copy says exactly that.
|
||||
*
|
||||
* The narrower consent for reading documents (`ai_extraction`) gets its own
|
||||
* row once one is on file, with its own withdrawal. It is granted where a
|
||||
* document is read, so this is where it is taken back.
|
||||
* ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
import { useState } from "react";
|
||||
@@ -26,6 +35,7 @@ import { Loader2, ShieldCheck, ShieldOff } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -47,11 +57,13 @@ export function AiConsentCard({
|
||||
const queryClient = useQueryClient();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.aiConsentReceipt("ai_full"),
|
||||
queryFn: async () => {
|
||||
const res = await apiFetchRaw("/api/consent/ai/latest?kind=ai_full");
|
||||
if (!res.ok) return null;
|
||||
// A failed read is not a withdrawal: throw so the card says it could
|
||||
// not load instead of reporting the person's decision wrongly.
|
||||
if (!res.ok) throw new Error("consent read failed");
|
||||
const json = await res.json();
|
||||
return (json.data?.receipt ?? null) as ConsentReceiptWire;
|
||||
},
|
||||
@@ -69,10 +81,12 @@ export function AiConsentCard({
|
||||
onSuccess: async () => {
|
||||
setConfirming(false);
|
||||
// The receipt gates every AI surface, so anything that reads consent
|
||||
// state has to re-resolve — not just this card.
|
||||
// state has to re-resolve — not just this card. `/me` carries the
|
||||
// resolved capabilities every AI surface renders from.
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.aiConsentReceipt("ai_full"),
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.insightsProviderChain(),
|
||||
});
|
||||
@@ -96,6 +110,7 @@ export function AiConsentCard({
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.aiConsentReceipt("ai_full"),
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.insightsProviderChain(),
|
||||
});
|
||||
@@ -121,6 +136,17 @@ export function AiConsentCard({
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<section className="space-y-3" data-slot="ai-consent">
|
||||
<QueryErrorRow
|
||||
slot="ai-consent-load-error"
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Narrow to the receipt itself rather than a boolean, so the branches
|
||||
// below can read its fields without an assertion.
|
||||
const activeReceipt = data && data.revokedAt === null ? data : null;
|
||||
@@ -217,6 +243,119 @@ export function AiConsentCard({
|
||||
{t("settings.ai.consent.grantError")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<DocumentReadingConsentRow isAuthenticated={isAuthenticated} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The consent for reading documents (`ai_extraction`), shown only while one
|
||||
* is on file. Withdrawing it stops documents, lab report scans and medication
|
||||
* text from being sent for reading at once; it deletes nothing, because what
|
||||
* a read produced (a document summary, extracted values) is the person's own
|
||||
* record.
|
||||
*/
|
||||
function DocumentReadingConsentRow({
|
||||
isAuthenticated,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.aiConsentReceipt("ai_extraction"),
|
||||
queryFn: async () => {
|
||||
const res = await apiFetchRaw(
|
||||
"/api/consent/ai/latest?kind=ai_extraction",
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
return (json.data?.receipt ?? null) as ConsentReceiptWire;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const revoke = useMutation({
|
||||
mutationKey: queryKeys.aiConsentReceipt("ai_extraction"),
|
||||
mutationFn: async () => {
|
||||
const res = await apiFetchRaw(
|
||||
"/api/consent/ai/latest?kind=ai_extraction",
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!res.ok) throw new Error("revoke failed");
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setConfirming(false);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.aiConsentReceipt("ai_extraction"),
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
},
|
||||
});
|
||||
|
||||
const active = data && data.revokedAt === null ? data : null;
|
||||
if (!active) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="ai-consent-document-reading"
|
||||
className="border-border space-y-2 border-t pt-3"
|
||||
>
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.ai.consent.documentReading.title")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.ai.consent.documentReading.activeSince", {
|
||||
date: formatDateTime(active.signedAt),
|
||||
})}
|
||||
</p>
|
||||
{confirming ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-muted-foreground w-full text-xs">
|
||||
{t("settings.ai.consent.documentReading.confirmBody")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
data-slot="ai-consent-document-reading-withdraw-confirm"
|
||||
onClick={() => revoke.mutate()}
|
||||
disabled={revoke.isPending}
|
||||
>
|
||||
{revoke.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("settings.ai.consent.confirmWithdraw")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setConfirming(false)}
|
||||
disabled={revoke.isPending}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-slot="ai-consent-document-reading-withdraw"
|
||||
onClick={() => setConfirming(true)}
|
||||
>
|
||||
{t("settings.ai.consent.withdraw")}
|
||||
</Button>
|
||||
)}
|
||||
{revoke.isError ? (
|
||||
<p className="text-destructive text-xs" role="alert">
|
||||
{t("settings.ai.consent.error")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { ShieldAlert } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
@@ -31,6 +32,12 @@ interface AutoReadPref {
|
||||
export function AutoReadCard() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
// With the operator's "Reading documents" switch (or all AI) off, nothing
|
||||
// would be read whatever this says, so the switch is shown disabled with
|
||||
// the reason instead of a control that silently does nothing. Turning an
|
||||
// already-on preference off stays possible.
|
||||
const documentAi = useAiCapability("documentAi");
|
||||
const operatorOff = documentAi.reason === "operator_disabled";
|
||||
|
||||
// Whether the honesty confirm is currently shown (user flipped the switch ON
|
||||
// but has not yet acknowledged the trade). Off→on reveals it; the write only
|
||||
@@ -54,10 +61,12 @@ export function AutoReadCard() {
|
||||
queryClient.setQueryData(queryKeys.documentsAutoAiRead(), result);
|
||||
// Flipping the toggle changes whether an ambient/per-document read
|
||||
// egresses without a per-document consent step — refresh the capability
|
||||
// probe the vault UI reads.
|
||||
// probe the vault UI reads, and `/me`, whose `documentAi` capability
|
||||
// reflects the consent the toggle records.
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.inboundDocumentAiCapability(),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
setPendingEnable(false);
|
||||
},
|
||||
});
|
||||
@@ -92,13 +101,22 @@ export function AutoReadCard() {
|
||||
</div>
|
||||
<Switch
|
||||
checked={checked}
|
||||
disabled={busy}
|
||||
disabled={busy || (operatorOff && !enabled)}
|
||||
onCheckedChange={onSwitch}
|
||||
aria-label={t("settings.ai.autoRead.title")}
|
||||
data-testid="documents-auto-read-enable"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{operatorOff ? (
|
||||
<p
|
||||
data-slot="documents-auto-read-operator-off"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("settings.ai.operatorOff.autoRead")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{pendingEnable ? (
|
||||
<div
|
||||
data-slot="documents-auto-read-confirm"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { PowerOff } from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useAiCapabilityMap } from "@/hooks/use-ai-capability";
|
||||
import {
|
||||
AI_CAPABILITIES,
|
||||
AI_CAPABILITY_KEYS,
|
||||
AI_OPERATOR_SWITCHES,
|
||||
type AiOperatorSwitch,
|
||||
} from "@/lib/ai/capabilities/types";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* Says, in Settings → AI, what the person running this server has switched
|
||||
* off, so the settings below never read as broken.
|
||||
*
|
||||
* Read from the resolved capabilities on `/api/auth/me`: a capability whose
|
||||
* reason is `operator_disabled` names the operator switch that covers it, and
|
||||
* the notice lists those switches by the names the admin panel uses. With
|
||||
* every switch off (the master switch) it says AI is off on this server. With
|
||||
* none off it renders nothing. It never offers a control: nothing on this
|
||||
* screen can change an operator's switch.
|
||||
*/
|
||||
export function OperatorOffNotice() {
|
||||
const { t } = useTranslations();
|
||||
const capabilities = useAiCapabilityMap();
|
||||
if (!capabilities) return null;
|
||||
|
||||
const off = new Set<AiOperatorSwitch>();
|
||||
for (const key of AI_CAPABILITY_KEYS) {
|
||||
if (capabilities[key]?.reason === "operator_disabled") {
|
||||
off.add(AI_CAPABILITIES[key].operatorSwitch);
|
||||
}
|
||||
}
|
||||
if (off.size === 0) return null;
|
||||
|
||||
const allOff = AI_OPERATOR_SWITCHES.every((s) => off.has(s));
|
||||
const names = AI_OPERATOR_SWITCHES.filter((s) => off.has(s)).map((s) =>
|
||||
t(`admin.assistant.${s}.title`),
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="ai-operator-off-notice">
|
||||
<SettingsCardHeader
|
||||
icon={PowerOff}
|
||||
title={
|
||||
allOff
|
||||
? t("settings.ai.operatorOff.allTitle")
|
||||
: t("settings.ai.operatorOff.someTitle")
|
||||
}
|
||||
/>
|
||||
<p className="text-foreground text-sm">
|
||||
{allOff
|
||||
? t("settings.ai.operatorOff.allBody")
|
||||
: t("settings.ai.operatorOff.someBody", {
|
||||
switches: names.join(", "),
|
||||
})}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { apiFetchRaw, apiPut } from "@/lib/api/api-fetch";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import {
|
||||
@@ -39,6 +40,12 @@ export function RuntimeActionsRow({
|
||||
onPrivacyChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
// "Regenerate" writes the daily briefing, so it is offered only while the
|
||||
// `briefing` capability is available (operator switch, AI analysis opt-out,
|
||||
// provider, consent). The connection test stays: it is how a provider gets
|
||||
// checked before anything else can be on.
|
||||
const briefing = useAiCapability("briefing");
|
||||
const showRegenerate = canRegenerate && briefing.available;
|
||||
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testMsg, setTestMsg] = useState<string | null>(null);
|
||||
@@ -80,13 +87,18 @@ export function RuntimeActionsRow({
|
||||
setTestMsg(t("settings.ai.testUnexpectedResponse"));
|
||||
return;
|
||||
}
|
||||
// 4xx config errors still arrive via the error envelope.
|
||||
// 4xx config errors still arrive via the error envelope. A refusal
|
||||
// because the operator switched AI off says so in words.
|
||||
if (!res.ok) {
|
||||
setTestOk(false);
|
||||
const code = (json as { meta?: { errorCode?: unknown } | null }).meta
|
||||
?.errorCode;
|
||||
setTestMsg(
|
||||
t("settings.ai.testFailedShort", {
|
||||
message: json.error ?? `HTTP ${res.status}`,
|
||||
}),
|
||||
typeof code === "string" && code.startsWith("assistant.disabled.")
|
||||
? t("settings.ai.operatorOff.testRefused")
|
||||
: t("settings.ai.testFailedShort", {
|
||||
message: json.error ?? `HTTP ${res.status}`,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -204,7 +216,7 @@ export function RuntimeActionsRow({
|
||||
// setting and every status line (last-generated, test / regenerate
|
||||
// result) sit ABOVE it, never under it.
|
||||
<div className="space-y-3">
|
||||
{canRegenerate && (
|
||||
{showRegenerate && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="pr-2">
|
||||
@@ -264,7 +276,7 @@ export function RuntimeActionsRow({
|
||||
)}
|
||||
{t("settings.ai.testProvider")}
|
||||
</Button>
|
||||
{canRegenerate && (
|
||||
{showRegenerate && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
||||
@@ -44,6 +44,11 @@ import { HealthProfileFactsManager } from "@/components/records/health-profile-f
|
||||
|
||||
export function AnamnesisSection() {
|
||||
const { t } = useTranslations();
|
||||
// The inclusion card and the about-me note only feed AI surfaces, so they
|
||||
// follow the person's two AI opt-outs: the Coach module and "AI analysis"
|
||||
// (the `insights` module, which since v1.39 governs model-written text and
|
||||
// nothing else). They are opt-outs, not capabilities, on purpose: somebody
|
||||
// without a provider yet can still write the note the AI will read later.
|
||||
const coachEnabled = useModuleEnabled("coach");
|
||||
const insightsEnabled = useModuleEnabled("insights");
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { MessagesSquare, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { ConversationTranscript } from "@/components/insights/coach-panel/conversation-transcript";
|
||||
import {
|
||||
useCoachConversationHistory,
|
||||
useDeleteCoachConversationWithUndo,
|
||||
} from "@/components/insights/coach-panel/use-coach";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatRelativeTime } from "@/lib/i18n/relative-time";
|
||||
|
||||
/**
|
||||
* The Coach's stored conversations, readable and deletable from Settings.
|
||||
*
|
||||
* A conversation is the person's own record, not a cache, so it stays
|
||||
* reachable whatever the Coach's state: switched off by the operator, hidden
|
||||
* by the person, without a provider or without consent. Each row opens its
|
||||
* transcript in place and can be deleted (with the same undo window the
|
||||
* conversation list uses). Reading and deleting never ask the Coach; the
|
||||
* routes behind them are data routes.
|
||||
*
|
||||
* `hideWhenEmpty`: the card renders nothing when there is nothing stored.
|
||||
* Settings → AI mounts it that way while the Coach is off, so it appears only
|
||||
* when there is something to see.
|
||||
*/
|
||||
export function CoachConversationsMemoryCard({
|
||||
isAuthenticated,
|
||||
hideWhenEmpty = false,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
hideWhenEmpty?: boolean;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const history = useCoachConversationHistory({ enabled: isAuthenticated });
|
||||
const { pendingDeleteIds, requestDelete, undoDelete } =
|
||||
useDeleteCoachConversationWithUndo();
|
||||
|
||||
const visible = useMemo(
|
||||
() => history.conversations.filter((c) => !pendingDeleteIds.has(c.id)),
|
||||
[history.conversations, pendingDeleteIds],
|
||||
);
|
||||
|
||||
if (hideWhenEmpty && (history.isLoading || history.isError)) return null;
|
||||
if (hideWhenEmpty && visible.length === 0) return null;
|
||||
|
||||
function remove(id: string) {
|
||||
requestDelete(id);
|
||||
if (openId === id) setOpenId(null);
|
||||
toast.success(t("insights.coach.historyDeleted"), {
|
||||
action: { label: t("common.undo"), onClick: () => undoDelete(id) },
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
as="section"
|
||||
aria-labelledby="settings-coach-conversations-title"
|
||||
data-testid="settings-coach-conversations-card"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={MessagesSquare}
|
||||
titleId="settings-coach-conversations-title"
|
||||
title={t("settings.ai.coachConversations.title")}
|
||||
description={t("settings.ai.coachConversations.description")}
|
||||
/>
|
||||
{history.isError ? (
|
||||
<QueryErrorRow onRetry={() => void history.refetch()} />
|
||||
) : visible.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{history.isLoading
|
||||
? t("common.loading")
|
||||
: t("insights.coach.historyEmpty")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{visible.map((c) => (
|
||||
<li
|
||||
key={c.id}
|
||||
data-testid="settings-coach-conversation"
|
||||
className="border-border bg-background rounded-lg border"
|
||||
>
|
||||
<div className="flex items-center gap-2 p-1.5 pl-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setOpenId((current) => (current === c.id ? null : c.id))
|
||||
}
|
||||
aria-expanded={openId === c.id}
|
||||
data-slot="settings-coach-conversation-open"
|
||||
className="flex min-h-11 min-w-0 flex-1 flex-col justify-center text-left"
|
||||
>
|
||||
<span className="truncate text-sm font-medium">
|
||||
{c.title}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{formatRelativeTime(c.updatedAt, t, locale)}
|
||||
</span>
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-lg"
|
||||
onClick={() => remove(c.id)}
|
||||
aria-label={t("insights.coach.historyDeleteAria")}
|
||||
data-slot="settings-coach-conversation-delete"
|
||||
className="shrink-0"
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
{openId === c.id ? <ConversationTranscript id={c.id} /> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{history.hasNextPage ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 self-start sm:min-h-9"
|
||||
disabled={history.isFetchingNextPage}
|
||||
onClick={() => void history.fetchNextPage()}
|
||||
>
|
||||
{t("settings.ai.coachConversations.more")}
|
||||
</Button>
|
||||
) : null}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -14,9 +14,14 @@
|
||||
*
|
||||
* Reads unwrap `(await res.json()).data` per the envelope convention;
|
||||
* every read/write routes its key through `queryKeys.coachFacts()` so
|
||||
* a forget invalidates the list. Gated on `!user.disableCoach` so the
|
||||
* panel mirrors the rest of the Coach surface — hiding the Coach hides
|
||||
* its memory controls too.
|
||||
* a forget invalidates the list.
|
||||
*
|
||||
* v1.39 — never gated on the Coach. What the Coach stored is the person's
|
||||
* own record: it stays readable and deletable while the Coach is off for any
|
||||
* reason (the operator's switch, Hide Coach, no provider, no consent). The
|
||||
* facts routes are data routes and ask no AI gate. Settings → Coach always
|
||||
* shows this card; while that page is not reachable, Settings → AI shows it
|
||||
* through `<StoredCoachMemory>` below, whenever rows exist.
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -45,6 +50,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { toast } from "sonner";
|
||||
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
|
||||
import { CoachConversationsMemoryCard } from "@/components/settings/coach-conversations-memory-card";
|
||||
|
||||
/** Closed enum mirrored from the server `CoachFact.category` column. */
|
||||
const FACT_CATEGORIES = [
|
||||
@@ -81,8 +87,11 @@ async function fetchFacts(): Promise<CoachFact[]> {
|
||||
|
||||
export function CoachMemorySection({
|
||||
isAuthenticated,
|
||||
hideWhenEmpty = false,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
/** Render nothing while there are no stored facts (the Coach-off mount). */
|
||||
hideWhenEmpty?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -138,6 +147,9 @@ export function CoachMemorySection({
|
||||
})).filter((g) => g.items.length > 0);
|
||||
}, [facts]);
|
||||
|
||||
if (hideWhenEmpty && (query.isPending || query.isError)) return null;
|
||||
if (hideWhenEmpty && facts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
as="section"
|
||||
@@ -279,3 +291,25 @@ export function CoachMemorySection({
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the Coach stored, for Settings → AI while Settings → Coach is not
|
||||
* reachable (the Coach switched off by the operator or hidden by the person).
|
||||
* Each card renders only when it has rows, so a person who never used the
|
||||
* Coach sees nothing here.
|
||||
*/
|
||||
export function StoredCoachMemory({
|
||||
isAuthenticated,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<CoachMemorySection isAuthenticated={isAuthenticated} hideWhenEmpty />
|
||||
<CoachConversationsMemoryCard
|
||||
isAuthenticated={isAuthenticated}
|
||||
hideWhenEmpty
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -16,9 +16,11 @@
|
||||
* notification).
|
||||
* 4. Coach memory — durable-fact review + forget controls.
|
||||
*
|
||||
* The nav entry is module-gated on `coach`. The preference + nudge + memory
|
||||
* cards keep their own content gate on `!user.disableCoach` (mirrored from the
|
||||
* old AI section): hiding the Coach hides its tuning + memory controls too.
|
||||
* The nav entry is module-gated on `coach`. The preference + nudge +
|
||||
* reminder cards keep their own content gate on `!user.disableCoach`: hiding
|
||||
* the Coach hides its tuning. The memory (facts and stored conversations) is
|
||||
* never gated: it is the person's own record, readable and deletable whatever
|
||||
* the Coach's state.
|
||||
*
|
||||
* The "about me" context lives in the AI section, not here — the daily
|
||||
* briefing reads it too, so it is not a Coach-only setting.
|
||||
@@ -29,6 +31,7 @@
|
||||
|
||||
import { DisableCoachCard } from "@/components/settings/ai/disable-coach-card";
|
||||
import { CoachMemorySection } from "@/components/settings/coach-memory-section";
|
||||
import { CoachConversationsMemoryCard } from "@/components/settings/coach-conversations-memory-card";
|
||||
import { CoachRemindersSection } from "@/components/settings/coach-reminders-section";
|
||||
import { CoachNudgeCard } from "@/components/settings/coach-nudge-card";
|
||||
import { CoachPrefsSection } from "@/components/settings/coach-prefs-section";
|
||||
@@ -62,7 +65,9 @@ export function CoachSection() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{coachEnabled && <CoachMemorySection isAuthenticated={authed} />}
|
||||
<CoachMemorySection isAuthenticated={authed} />
|
||||
|
||||
<CoachConversationsMemoryCard isAuthenticated={authed} />
|
||||
|
||||
{coachEnabled && <CoachRemindersSection isAuthenticated={authed} />}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { LayoutGrid, Loader2 } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
|
||||
import { InsightsEditMode } from "@/components/insights/insights-edit-mode";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
@@ -35,15 +35,15 @@ import { isSurfaceVisible } from "@/lib/modules/surface";
|
||||
export function InsightsOverviewArrangeSection({ id }: { id?: string }) {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const flags = useFeatureFlags();
|
||||
// The daily briefing is model-written: its row is live only while the
|
||||
// `briefing` capability is available. The period review always renders
|
||||
// (its narrative is composed from the numbers), so it stays a live row.
|
||||
const briefing = useAiCapability("briefing");
|
||||
const { layout, isLoading } = useInsightsLayoutQuery(isAuthenticated);
|
||||
|
||||
const gatedOffSectionIds = useMemo(() => {
|
||||
const gated = new Set<InsightsSectionId>();
|
||||
if (!flags.briefing) {
|
||||
gated.add("daily-briefing");
|
||||
gated.add("period-review");
|
||||
}
|
||||
if (!briefing.available) gated.add("daily-briefing");
|
||||
// A block whose module is off cannot appear on the overview, so its row
|
||||
// is not offered as a live toggle. Read from the one surface map the
|
||||
// overview renders from (`overview:<id>`); cycle follows the resolved
|
||||
@@ -52,7 +52,7 @@ export function InsightsOverviewArrangeSection({ id }: { id?: string }) {
|
||||
if (!isSurfaceVisible(`overview:${id}`, user?.modules)) gated.add(id);
|
||||
}
|
||||
return gated;
|
||||
}, [flags.briefing, user?.modules]);
|
||||
}, [briefing.available, user?.modules]);
|
||||
|
||||
return (
|
||||
<section
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
* off `useAuth().user.modules`.
|
||||
*
|
||||
* Operator precedence stays honest: a module the operator turned off
|
||||
* server-wide (the module-availability blob, plus the assistant master flag
|
||||
* `flags.coach` for the coach row) renders a disabled switch + a
|
||||
* server-wide (the module-availability blob, or for the coach row the
|
||||
* operator's Coach switch as the `coach` capability reports it) renders a disabled switch + a
|
||||
* "disabled server-wide" hint — a per-user toggle could not re-enable it.
|
||||
*
|
||||
* The three CORE domains (weight, blood pressure, pulse) render as a
|
||||
@@ -63,7 +63,7 @@ import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { ModuleToggleRow } from "@/components/settings/module-toggle-row";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
@@ -118,7 +118,7 @@ const MODULE_ICONS: Record<ModuleKey, LucideIcon> = {
|
||||
export function ModulesSection() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const flags = useFeatureFlags();
|
||||
const coach = useAiCapability("coach");
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const modules = user?.modules ?? {};
|
||||
@@ -234,13 +234,13 @@ export function ModulesSection() {
|
||||
|
||||
// Operator precedence. A per-user toggle can never re-enable a
|
||||
// module the operator turned off server-wide, so the switch goes
|
||||
// disabled + hint. For coach the operator layer is BOTH the
|
||||
// module-availability blob AND the assistant master flag
|
||||
// (`flags.coach`, already master-composed); for cycle and the
|
||||
// owned modules it is the module-availability blob alone.
|
||||
// disabled + hint. For coach the operator layer is the
|
||||
// operator's Coach switch, read from the resolved `coach`
|
||||
// capability (`operator_disabled`, master applied); for cycle
|
||||
// and the owned modules it is the module-availability blob.
|
||||
const operatorAvailable =
|
||||
delegate === "coach"
|
||||
? moduleAvailability[key] !== false && flags.coach
|
||||
? coach.reason !== "operator_disabled"
|
||||
: moduleAvailability[key] !== false;
|
||||
const disabledReason = operatorAvailable
|
||||
? undefined
|
||||
|
||||
Reference in new issue
Block a user