Merge branch 'feat/ai-web-surfaces' into release/v1.39.0

This commit is contained in:
Marc-André Bombeck committed 2026-09-24 11:37:10 +02:00
commit e0ece41e21
120 files changed
+3851 -2027

No files matched your search

@@ -0,0 +1,214 @@
/**
* Every web request to an AI route is behind the `ai` capability map, and no
* web code reads the retired switch projection.
*
* Since v1.39 the web renders every AI surface from one answer, the `ai`
* block on `GET /api/auth/me`, read through `useAiCapability`. Two ways back
* to the old behaviour are frozen here:
*
* 1. Reading `GET /api/feature-flags` (or the deleted `useFeatureFlags`
* hook). The projection knew only the operator's switches, not the
* person's opt-out, the provider or the consent, and it failed open, so
* AI chrome flashed and fired refusals before the switch set arrived.
* 2. A request to an AI route from a module nobody gates. Each such module
* is on `AI_REQUEST_SITES` with the capability that decides it and the
* file that reads that capability; the guard proves the file does read
* it. A new module that requests an AI route without an entry fails by
* name.
*
* Matchers are whitespace-tolerant (`useAiCapability ( "coach" )` counts) and
* run on comment-stripped source, so prose naming a route or the hook is not
* a hit. Each discovery asserts a non-zero count: a sweep that finds nothing
* would agree with every allowlist, so an empty match set fails instead.
*
* Its honest limit: it recognises a request by the route literal. A route
* built from pieces (`"/api/insights/" + name`) or read from a constant in
* another module would slip it; `AI_ROUTE_PATTERNS` lists the literal shapes
* the tree uses today.
*/
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { AiCapabilityKey } from "@/lib/ai/capabilities/types";
import { stripComments, walkSourceFiles } from "./helpers/source-files";
const SRC = path.resolve(__dirname, "..");
const NON_TEST_FILES = walkSourceFiles(SRC, { floor: 1500 }).filter(
(rel) =>
!rel.startsWith("app/api/") &&
!rel.startsWith("generated/") &&
!rel.includes("__tests__/") &&
!/\.test\.tsx?$/.test(rel),
);
/**
* Web code for the request sweep: pages, components, hooks. `lib/` is left
* out of that sweep because it mixes server modules (the jobs and route
* helpers name the same routes) with client ones; the retired-projection
* check below walks it too, minus the switch loader and the API contract,
* which describe the route rather than read it.
*/
const CLIENT_FILES = NON_TEST_FILES.filter((rel) => !rel.startsWith("lib/"));
const FEATURE_FLAG_SWEEP = NON_TEST_FILES.filter(
(rel) =>
!rel.startsWith("lib/feature-flags/") && !rel.startsWith("lib/openapi/"),
);
const read = (rel: string) =>
stripComments(readFileSync(path.join(SRC, rel), "utf8"));
/** Request literals of routes that call a model or serve model text. */
const AI_ROUTE_PATTERNS: readonly RegExp[] = [
/["'`]\/api\/insights\/generate["'`?]/,
/["'`]\/api\/insights\/\$\{\s*metric\s*\}-status/,
/["'`]\/api\/insights\/metric-status\?/,
/["'`]\/api\/insights\/biomarker-assessment\?/,
/["'`]\/api\/insights\/medication-compliance-status\?/,
/["'`]\/api\/insights\/coach\/nudge-status["'`]/,
/["'`]\/api\/insights\/chat["'`]/,
/["'`]\/api\/insights\/chat\/fenced["'`]/,
/["'`]\/api\/medications\/extract["'`]/,
/["'`]\/api\/labs\/ocr\/capability["'`]/,
/["'`]\/api\/documents\/inbound\/capability["'`]/,
/["'`]\/api\/ai\/test["'`]/,
];
interface RequestSite {
capability: AiCapabilityKey | null;
/** The file that reads the capability and keeps the request from firing. */
gate: string;
/** Why, when `capability` is null. */
reason?: string;
}
/**
* Every client module that requests an AI route, keyed by path under src/.
* Adding a request site means adding it here with the capability that decides
* it; the guard then checks the gate file really reads that capability.
*/
const AI_REQUEST_SITES: Record<string, RequestSite> = {
"components/insights/use-insights-advisor.ts": {
capability: "briefing",
gate: "components/insights/use-insights-advisor.ts",
},
"components/settings/ai/runtime-actions-row.tsx": {
capability: "briefing",
gate: "components/settings/ai/runtime-actions-row.tsx",
},
"hooks/use-insight-status.ts": {
capability: "statusText",
gate: "hooks/use-insight-status.ts",
},
"app/insights/medications/page.tsx": {
capability: "statusText",
gate: "app/insights/medications/page.tsx",
},
"components/insights/layout-coach-fab.tsx": {
capability: "coach",
gate: "components/insights/layout-coach-fab.tsx",
},
"app/coach/page-client.tsx": {
capability: "coach",
gate: "app/coach/page-client.tsx",
},
// The chat hooks run only inside the drawer, which mounts only while the
// Coach is available, and on /coach, which leaves while it is not.
"components/insights/coach-panel/use-coach.ts": {
capability: "coach",
gate: "components/insights/layout-coach-mount.tsx",
},
"components/medications/scheduling/natural-language-extractor.tsx": {
capability: "medicationExtract",
gate: "components/medications/wizard/medication-wizard-dialog.tsx",
},
"components/labs/use-ocr-extract.ts": {
capability: "labsOcr",
gate: "app/labs/page.tsx",
},
"components/documents/use-document-assist.ts": {
capability: "documentAi",
gate: "components/documents/document-detail-sheet.tsx",
},
};
/** Requests to AI routes that no capability decides, with the reason. */
const UNGATED_BY_DESIGN: Record<string, string> = {
// `POST /api/ai/test` checks a provider before anything can be on; the
// route answers the operator's master switch itself with a typed refusal.
"components/settings/ai/runtime-actions-row.tsx:/api/ai/test":
"the connection test is how a provider gets checked",
};
function readsCapability(source: string, key: AiCapabilityKey): boolean {
return new RegExp(
`useAiCapability(?:Answer)?\\s*\\(\\s*["']${key}["']\\s*\\)`,
).test(source);
}
describe("AI surfaces read the capability map", () => {
it("walks the web tree", () => {
expect(CLIENT_FILES.length).toBeGreaterThan(800);
});
it("no web code reads GET /api/feature-flags or the retired hook", () => {
expect(FEATURE_FLAG_SWEEP.length).toBeGreaterThan(1500);
const offenders = FEATURE_FLAG_SWEEP.filter((rel) => {
const src = read(rel);
return (
/["'`]\/api\/feature-flags/.test(src) ||
/["']@\/hooks\/use-feature-flags["']/.test(src) ||
/\buseFeatureFlags\s*\(/.test(src)
);
});
expect(offenders).toEqual([]);
});
it("every request to an AI route comes from a registered, gated module", () => {
const discovered = CLIENT_FILES.filter((rel) => {
const src = read(rel);
return AI_ROUTE_PATTERNS.some((pattern) => pattern.test(src));
});
// A sweep that finds nothing agrees with every registry.
expect(discovered.length).toBeGreaterThanOrEqual(8);
const unregistered = discovered.filter((rel) => !(rel in AI_REQUEST_SITES));
expect(
unregistered,
"Module(s) request an AI route but are not on AI_REQUEST_SITES",
).toEqual([]);
const stale = Object.keys(AI_REQUEST_SITES).filter(
(rel) => !discovered.includes(rel),
);
expect(stale, "AI_REQUEST_SITES entries that no longer request").toEqual(
[],
);
});
it("each registered gate reads the capability it names", () => {
const missing = Object.entries(AI_REQUEST_SITES)
.filter(([, site]) => site.capability !== null)
.filter(([, site]) => !readsCapability(read(site.gate), site.capability!))
.map(([rel, site]) => `${rel} -> ${site.gate} (${site.capability})`);
expect(missing).toEqual([]);
});
it("the ungated exceptions are real", () => {
for (const key of Object.keys(UNGATED_BY_DESIGN)) {
const [rel, route] = key.split(":");
expect(read(rel)).toContain(route);
}
});
it("the account payload's ai block has web readers beyond the hook", () => {
const readers = CLIENT_FILES.filter((rel) =>
/useAiCapability(?:Answer|Map)?\s*\(/.test(read(rel)),
).filter((rel) => rel !== "hooks/use-ai-capability.ts");
expect(readers.length).toBeGreaterThanOrEqual(20);
});
});
@@ -307,6 +307,11 @@ const PINNED_AFFORDANCES: Record<
"text-success": 5,
"toast.success": 1,
},
// The delete-with-undo toast, the same one the conversation list and the
// history rail raise for the same delete.
"src/components/settings/coach-conversations-memory-card.tsx": {
"toast.success": 1,
},
"src/components/settings/coach-memory-section.tsx": { "toast.success": 2 },
"src/components/settings/coach-prefs-section.tsx": { "toast.success": 1 },
"src/components/settings/dashboard-layout-section.tsx": {
@@ -26,8 +26,6 @@ const REQUIRED_KEYS = [
// Existing terminal contracts reused by the final v1.34.1 call sites.
"insights.assessmentPreparing",
"insights.noAnalysisYet",
"insights.noProviderConfigured",
"insights.noProviderAction",
"common.loadFailed",
"common.networkError",
"settings.googleHealthSyncResult",
@@ -37,11 +37,12 @@ vi.mock("next/navigation", () => ({
vi.mock("@/lib/insights/coach-launch-context", () => ({
useCoachLaunch: () => ({ askCoach: vi.fn() }),
}));
vi.mock("@/hooks/use-feature-flags", () => ({
useFeatureFlags: () => ({ coach: true }),
}));
vi.mock("@/hooks/use-disable-coach", () => ({
useDisableCoach: () => false,
vi.mock("@/hooks/use-ai-capability", () => ({
useAiCapabilityAnswer: () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: {}, isLoading: false }),
@@ -38,11 +38,12 @@ vi.mock("next/navigation", () => ({
vi.mock("@/lib/insights/coach-launch-context", () => ({
useCoachLaunch: () => ({ askCoach: vi.fn() }),
}));
vi.mock("@/hooks/use-feature-flags", () => ({
useFeatureFlags: () => ({ coach: true }),
}));
vi.mock("@/hooks/use-disable-coach", () => ({
useDisableCoach: () => false,
vi.mock("@/hooks/use-ai-capability", () => ({
useAiCapabilityAnswer: () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: {}, isLoading: false }),
+47 -36
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
@@ -19,6 +19,7 @@ import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { COACH_SCROLLBAR } from "@/components/insights/coach-panel/message-thread";
import { ConversationRename } from "@/components/insights/coach-panel/conversation-rename";
import { ConversationTranscript } from "@/components/insights/coach-panel/conversation-transcript";
import {
useCoachConversationHistory,
useDeleteCoachConversationWithUndo,
@@ -26,8 +27,7 @@ import {
import type { CoachConversationDTO } from "@/lib/ai/coach/types";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
import { useAiCapabilityAnswer } from "@/hooks/use-ai-capability";
import { useLoadMoreSentinel } from "@/hooks/use-load-more-sentinel";
import { useTranslations } from "@/lib/i18n/context";
import { formatRelativeTime } from "@/lib/i18n/relative-time";
@@ -50,10 +50,12 @@ import { cn } from "@/lib/utils";
* consumes — the two surfaces can no longer drift onto different
* pagination behaviour.
*
* Gating mirrors `/coach`: the operator master flag OR a per-user opt-out
* hides the Coach entirely and redirects back to `/insights` rather than
* painting a dead shell. The full-bleed sizing wrapper matches the Coach
* page so the two surfaces share one chrome.
* Stored conversations are the person's own records, so this page stays
* reachable while the Coach is unavailable for any reason (the operator's
* switch, Hide Coach, no provider, missing consent). It then turns read-only:
* a row opens its transcript inline instead of the Coach, and delete stays,
* so a thread can always be reviewed and erased. The full-bleed sizing
* wrapper matches the Coach page so the two surfaces share one chrome.
*/
type RecencyGroupId = "today" | "yesterday" | "thisWeek" | "earlier";
@@ -108,8 +110,9 @@ function groupByRecency(conversations: CoachConversationDTO[]): RecencyGroup[] {
.filter((g) => g.conversations.length > 0);
}
function CoachConversationsBody() {
function CoachConversationsBody({ readOnly }: { readOnly: boolean }) {
const { t, locale } = useTranslations();
const [openId, setOpenId] = useState<string | null>(null);
const router = useRouter();
const [filter, setFilter] = useState<string>("");
// v1.30.2 (QoL H1) — search now drives the server-side title query;
@@ -149,6 +152,11 @@ function CoachConversationsBody() {
});
function handleSelect(id: string) {
if (readOnly) {
// No Coach to open the thread in: show its transcript right here.
setOpenId((current) => (current === id ? null : id));
return;
}
// Reuse the existing `?c=<id>` open mechanism on the Coach page.
router.push(`/coach?c=${id}`);
}
@@ -175,18 +183,24 @@ function CoachConversationsBody() {
{t("insights.coach.historyTitle")}
</span>
}
description={
readOnly ? t("insights.coach.historyReadOnlyNote") : undefined
}
actions={
// Surface the sibling Plans ledger — otherwise it is reachable
// only from the composer's `+` menu.
<Button asChild variant="outline" size="sm">
<Link
href="/coach/plans"
data-slot="coach-conversations-plans-link"
>
<Target className="size-4" aria-hidden="true" />
{t("coach.plans.title")}
</Link>
</Button>
// only from the composer's `+` menu. Plans need the Coach, so the
// link stays away while it is unavailable.
readOnly ? undefined : (
<Button asChild variant="outline" size="sm">
<Link
href="/coach/plans"
data-slot="coach-conversations-plans-link"
>
<Target className="size-4" aria-hidden="true" />
{t("coach.plans.title")}
</Link>
</Button>
)
}
/>
<div className="relative">
@@ -257,7 +271,7 @@ function CoachConversationsBody() {
key={c.id}
data-slot="coach-conversations-item"
className={cn(
"group relative flex items-center gap-1.5 rounded-xl px-3 py-2.5",
"group relative flex flex-wrap items-center gap-1.5 rounded-xl px-3 py-2.5",
"text-sm transition-colors",
"text-muted-foreground hover:bg-muted/40 hover:text-foreground",
)}
@@ -267,6 +281,7 @@ function CoachConversationsBody() {
onClick={() => handleSelect(c.id)}
className="flex min-h-11 min-w-0 flex-1 flex-col justify-center text-left"
data-slot="coach-conversations-select"
aria-expanded={readOnly ? openId === c.id : undefined}
>
<span className="flex min-w-0 items-center gap-1.5 font-medium">
{/* v1.29.x (S7) — badge fenced threads (they run on
@@ -294,7 +309,9 @@ function CoachConversationsBody() {
{formatRelativeTime(c.updatedAt, t, locale)}
</span>
</button>
<ConversationRename id={c.id} title={c.title} />
{readOnly ? null : (
<ConversationRename id={c.id} title={c.title} />
)}
<Button
type="button"
variant="ghost"
@@ -306,6 +323,11 @@ function CoachConversationsBody() {
>
<Trash2 className="size-4" aria-hidden="true" />
</Button>
{readOnly && openId === c.id ? (
<div className="basis-full">
<ConversationTranscript id={c.id} />
</div>
) : null}
</li>
);
})}
@@ -339,27 +361,16 @@ function CoachConversationsBody() {
}
export default function CoachConversationsPage() {
const router = useRouter();
const launch = useCoachLaunch();
const flags = useFeatureFlags();
const disableCoach = useDisableCoach();
const coachUnavailable = !flags.coach || disableCoach;
// Same gating as `/coach`: operator master flag OR per-user opt-out
// redirects back to the Insights mother page so the route is never a
// dead-end.
useEffect(() => {
if (coachUnavailable) {
router.replace("/insights");
}
}, [coachUnavailable, router]);
// `null` until `/me` answers; then the page renders either way, read-only
// while the `coach` capability is unavailable.
const coach = useAiCapabilityAnswer("coach");
// Keep the launch context referenced so the shared FAB drawer the page
// hands back to stays mounted (mirrors `/coach`).
void launch;
if (coachUnavailable) return null;
if (coach === null) return null;
return (
<div
@@ -372,7 +383,7 @@ export default function CoachConversationsPage() {
// ~50px on notched devices; restored to match exactly.
className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]"
>
<CoachConversationsBody />
<CoachConversationsBody readOnly={!coach.available} />
</div>
);
}
+12 -11
View File
@@ -9,8 +9,7 @@ import { COACH_INTERRUPTED_STORAGE_KEY } from "@/components/insights/coach-panel
import type { CoachNudgeStatus } from "@/components/insights/layout-coach-fab";
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 { useAiCapabilityAnswer } from "@/hooks/use-ai-capability";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { queryKeys } from "@/lib/query-keys";
@@ -208,24 +207,26 @@ function CoachPageBody() {
export default function CoachPageClient() {
const router = useRouter();
const launch = useCoachLaunch();
const flags = useFeatureFlags();
const disableCoach = useDisableCoach();
// The `coach` capability on `/api/auth/me` is the one answer every Coach
// entry point reads. `null` until `/me` has answered, so a direct visit is
// never sent away on the loading frame.
const coach = useAiCapabilityAnswer("coach");
const { user, isLoading } = useAuth();
const { inSharedRecord } = useRecordCapabilities();
const coachUnavailable = !flags.coach || disableCoach;
const coachUnavailable = coach === null || !coach.available;
// Coach surface gating mirrors `<CoachLaunchButton>` / `<LayoutCoachFab>`:
// operator master flag OR per-user opt-out hides the Coach entirely.
// Send a direct navigator back to the Insights mother page so the
// route is never a dead-end.
// With the Coach unavailable for any reason (operator switch, Hide Coach,
// no provider, missing consent) a direct navigator goes back to the
// Insights overview, so the route is never a dead end. Stored
// conversations stay reachable under /coach/conversations.
const canRenderOwnCoach = user !== null && !isLoading && !inSharedRecord;
useEffect(() => {
if (canRenderOwnCoach && coachUnavailable) {
if (canRenderOwnCoach && coach !== null && !coach.available) {
router.replace("/insights");
}
}, [canRenderOwnCoach, coachUnavailable, router]);
}, [canRenderOwnCoach, coach, router]);
// Reopening the bottom-right FAB drawer is no longer wired to a page
// control, but keep the launch context referenced so the lint rule does
+7 -12
View File
@@ -24,8 +24,7 @@ import {
useCoachPlanMutations,
type CoachPlanDTO,
} from "@/hooks/use-coach-plans";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
import { useAiCapabilityAnswer } from "@/hooks/use-ai-capability";
import { useTranslations } from "@/lib/i18n/context";
import { formatDateOrRelative } from "@/lib/format";
import { cn } from "@/lib/utils";
@@ -279,21 +278,17 @@ function CoachPlansBody() {
export default function CoachPlansPage() {
const router = useRouter();
const flags = useFeatureFlags();
const disableCoach = useDisableCoach();
// Same gate as `/coach`: the `coach` capability, acted on only once
// `/me` has answered.
const coach = useAiCapabilityAnswer("coach");
const coachUnavailable = !flags.coach || disableCoach;
// Same gating as `/coach`: operator master flag OR per-user opt-out
// redirects back to the Insights mother page so the route is never a
// dead-end.
useEffect(() => {
if (coachUnavailable) {
if (coach !== null && !coach.available) {
router.replace("/insights");
}
}, [coachUnavailable, router]);
}, [coach, router]);
if (coachUnavailable) return null;
if (coach === null || !coach.available) return null;
return (
<div
+106 -52
View File
@@ -22,11 +22,12 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { TileHeader } from "@/components/insights/tile-header";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { useAuth } from "@/hooks/use-auth";
import { useWorkouts } from "@/hooks/use-workouts";
import { isSurfaceVisible } from "@/lib/modules/surface";
import { useTranslations } from "@/lib/i18n/context";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
@@ -135,7 +136,8 @@ function CatalogRow({
present,
}: {
entry: CatalogEntry;
present: boolean | null;
/** `"unknown"` while the read that decides this row failed. */
present: boolean | null | "unknown";
}) {
const { t } = useTranslations();
@@ -154,6 +156,7 @@ function CatalogRow({
}
const isPresent = present === true;
const unknown = present === "unknown";
return (
<div
@@ -166,12 +169,12 @@ function CatalogRow({
<span className="text-foreground text-sm font-medium">
{t(entry.nameKey)}
</span>
<StatusBadge present={isPresent} />
{unknown ? null : <StatusBadge present={isPresent} />}
</div>
<p className="text-muted-foreground text-xs">{t(entry.sourceKey)}</p>
</div>
<div className="shrink-0">
{isPresent && entry.href ? (
{unknown ? null : isPresent && entry.href ? (
<Button size="sm" variant="ghost" asChild>
<Link href={entry.href}>
{t("metricCatalog.viewCta")}
@@ -194,9 +197,35 @@ function CatalogRow({
);
}
/** Which read decides whether a catalog row has data. */
type ProbeId = "analytics" | "comprehensive" | "workouts" | "nutrients" | "ecg";
function probeForEntry(entry: CatalogEntry): ProbeId {
if (entry.id === "ecg") return "ecg";
if (entry.metric === "MOOD" || entry.metric === "MEDICATION") {
return "comprehensive";
}
if (entry.metric === "WORKOUTS") return "workouts";
if (entry.metric === "NUTRIENTS") return "nutrients";
return "analytics";
}
/**
* The surface id of the sub-page a catalog row links to, so a row whose
* module is switched off drops out through the one surface map. Rows without
* a sub-page (info rows, the ECG row) have no owner and always show.
*/
function entrySurfaceId(entry: CatalogEntry): string | null {
const slug = entry.href?.startsWith("/insights/")
? entry.href.slice("/insights/".length).split(/[/?#]/)[0]
: null;
return slug ? `insights-page:${slug}` : null;
}
export default function MetricCatalogPage() {
const { t } = useTranslations();
const { isAuthenticated, user } = useAuth();
const modules = user?.modules;
const analyticsQuery = useAnalyticsQuery({ slice: "summaries" });
@@ -206,7 +235,10 @@ export default function MetricCatalogPage() {
enabled: isAuthenticated,
});
const workoutsProbe = useWorkouts({ limit: 1 });
// Probe workouts only while the module shows; a switched-off module is
// never asked for rows, and its row is dropped below.
const workoutsVisible = isSurfaceVisible("insights-page:workouts", modules);
const workoutsProbe = useWorkouts({ limit: 1, enabled: workoutsVisible });
const nutrientsModuleEnabled = user?.modules?.nutrients === true;
const nutrientsProbe = useQuery({
@@ -236,6 +268,43 @@ export default function MetricCatalogPage() {
const grouped = catalogEntriesByGroup();
// M1 fix (`.planning/audits/2026-07-18-qa-ui.md`) — every probe backing
// `availability` is async; while one is in flight `hasMetricData()` reads
// an undefined summary and would report "absent". Each row waits on the
// ONE probe that decides it, and a failed probe marks only its own rows
// unknown with a retry inside their group: one failing read never replaces
// the whole page, and never reads as an honest "Not tracked yet".
const probes: Record<
ProbeId,
{ isLoading: boolean; isError: boolean; retry: () => void }
> = {
analytics: {
isLoading: analyticsQuery.isLoading,
isError: analyticsQuery.isError,
retry: () => void analyticsQuery.refetch(),
},
comprehensive: {
isLoading: comprehensiveQuery.isLoading,
isError: comprehensiveQuery.isError,
retry: () => void comprehensiveQuery.refetch(),
},
workouts: {
isLoading: workoutsProbe.isLoading,
isError: workoutsProbe.isError,
retry: () => workoutsProbe.refetch(),
},
nutrients: {
isLoading: nutrientsModuleEnabled && nutrientsProbe.isLoading,
isError: nutrientsModuleEnabled && nutrientsProbe.isError,
retry: () => void nutrientsProbe.refetch(),
},
ecg: {
isLoading: ecgQuery.isLoading,
isError: ecgQuery.isError,
retry: () => void ecgQuery.refetch(),
},
};
function isPresent(entry: CatalogEntry): boolean | null {
if (entry.kind === "info") return null;
if (entry.id === "ecg") return ecgQuery.data?.hasRecordings === true;
@@ -243,49 +312,11 @@ export default function MetricCatalogPage() {
return hasMetricData(entry.metric, availability);
}
// M1 fix (`.planning/audits/2026-07-18-qa-ui.md`) — every probe backing
// `availability` is async; while any of them is still in flight,
// `hasMetricData()` reads an undefined summary and reports "absent" for
// every metric. Gate the rows on the probes' aggregate loading/error
// state instead of letting a cold load or a failed batch masquerade as
// an honest "Not tracked yet".
const isLoading = Boolean(
isAuthenticated &&
(analyticsQuery.isLoading ||
comprehensiveQuery.isLoading ||
workoutsProbe.isLoading ||
ecgQuery.isLoading ||
(nutrientsModuleEnabled && nutrientsProbe.isLoading)),
);
const isError = Boolean(
analyticsQuery.isError ||
comprehensiveQuery.isError ||
workoutsProbe.isError ||
ecgQuery.isError ||
(nutrientsModuleEnabled && nutrientsProbe.isError),
);
function retryAll() {
void analyticsQuery.refetch();
void comprehensiveQuery.refetch();
workoutsProbe.refetch();
void ecgQuery.refetch();
if (nutrientsModuleEnabled) void nutrientsProbe.refetch();
}
if (isError) {
return (
<SubPageShell
title={t("metricCatalog.title")}
description={t("metricCatalog.description")}
>
<QueryErrorCard
description={t("metricCatalog.loadError")}
onRetry={retryAll}
/>
</SubPageShell>
);
function isEntryVisible(entry: CatalogEntry): boolean {
// Nutrients keeps its own module-off row with the way to switch it on.
if (entry.id === "nutrients") return true;
const surfaceId = entrySurfaceId(entry);
return surfaceId === null || isSurfaceVisible(surfaceId, modules);
}
return (
@@ -295,8 +326,16 @@ export default function MetricCatalogPage() {
>
<div className="space-y-6">
{CATALOG_GROUP_ORDER.map((group) => {
const entries = grouped.get(group) ?? [];
const entries = (grouped.get(group) ?? []).filter(isEntryVisible);
if (entries.length === 0) return null;
const failed = [
...new Set(
entries
.filter((entry) => entry.kind !== "info")
.map(probeForEntry)
.filter((id) => isAuthenticated && probes[id].isError),
),
];
const Icon = GROUP_ICONS[group];
// Nutrients rides the metabolic group but needs a module-off
// branch (B1 — the tab-strip pill requires the module ON before
@@ -314,7 +353,14 @@ export default function MetricCatalogPage() {
title={t(CATALOG_GROUP_HEADER_KEYS[group])}
/>
</CardHeader>
<CardContent>
<CardContent className="space-y-2">
{failed.length > 0 ? (
<QueryErrorRow
slot="catalog-group-error"
message={t("metricCatalog.loadError")}
onRetry={() => failed.forEach((id) => probes[id].retry())}
/>
) : null}
<div className="divide-border divide-y">
{entries.map((entry) => {
if (entry.id === "nutrients" && !nutrientsModuleEnabled) {
@@ -348,14 +394,22 @@ export default function MetricCatalogPage() {
</div>
);
}
if (isLoading && entry.kind !== "info") {
const probe =
entry.kind === "info"
? null
: probes[probeForEntry(entry)];
if (isAuthenticated && probe?.isLoading) {
return <CatalogRowSkeleton key={entry.id} />;
}
return (
<CatalogRow
key={entry.id}
entry={entry}
present={isPresent(entry)}
present={
isAuthenticated && probe?.isError
? "unknown"
: isPresent(entry)
}
/>
);
})}
+5 -1
View File
@@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { Loader2, Pill } from "lucide-react";
import { useAiCapability } from "@/hooks/use-ai-capability";
import { useAuth } from "@/hooks/use-auth";
import { nextStatusPollInterval } from "@/hooks/use-insight-status";
import { queryKeys } from "@/lib/query-keys";
@@ -93,6 +94,9 @@ interface MedicationComplianceDailyResponse {
export default function InsightsMedikamentePage() {
const { isAuthenticated } = useAuth();
const { t, locale } = useTranslations();
// The compliance summary and the per-medication sentences are status notes:
// read only while the `statusText` capability is available.
const statusText = useAiCapability("statusText");
const {
data: comprehensive,
@@ -124,7 +128,7 @@ export default function InsightsMedikamentePage() {
clearTimeout(timeoutHandle);
}
},
enabled: isAuthenticated,
enabled: isAuthenticated && statusText.available,
staleTime: 60 * 1000,
retry: 0,
// v1.8.4 — bounded preparing poll: stop after the shared attempt
+76 -66
View File
@@ -13,7 +13,7 @@ import { apiGet } from "@/lib/api/api-fetch";
// pulls neither the ECG label map/helpers nor the dynamically-loaded ECG
// component into this eager bundle.
import { ECG_OVERVIEW_LIMIT } from "@/lib/insights/ecg-overview-limit";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useAiCapability } from "@/hooks/use-ai-capability";
import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
@@ -26,10 +26,13 @@ import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
import { useTranslations } from "@/lib/i18n/context";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { HeroStrip } from "@/components/insights/hero-strip";
import { AiSetupHint } from "@/components/insights/ai-setup-hint";
import { OverviewSectionBoundary } from "@/components/insights/overview-section-boundary";
import { isSurfaceVisible } from "@/lib/modules/surface";
import { useCycleRingHasDial } from "@/components/cycle/use-cycle";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
@@ -74,9 +77,6 @@ function BlockSkeleton({
);
}
/** Stable reference so the trends row does not re-select on every render. */
const HIDDEN_MOOD = ["mood"] as const;
const DailyBriefing = dynamic(
() =>
import("@/components/insights/daily-briefing").then((mod) => ({
@@ -295,13 +295,11 @@ export default function InsightsPageClient() {
// callbacks).
useScrollResetOnRoute();
// v1.4.27 R3d MB4 — Coach drawer state lives in the layout-level
// `<CoachLaunchProvider>`; the drawer is mounted next to the provider in
// `src/app/insights/layout.tsx`. v1.18.7 removed the hero-band coach
// entry points (the action button + suggested-prompt chips), so the
// overview page no longer reads the launch context — the drawer + its
// own launcher stay untouched.
const flags = useFeatureFlags();
// The AI parts of the overview (the daily briefing card, the hero's
// briefing paragraph and the trend sentences, which all ride the advisor
// read) follow the `briefing` capability on `/api/auth/me`. Everything else
// on this page is data and never waits on it.
const briefing = useAiCapability("briefing");
// v1.4.36 W1 — drop the page-level `isLoading` gate that used to
// block the entire shell on `/api/insights/comprehensive`. The
@@ -312,7 +310,7 @@ export default function InsightsPageClient() {
// empty-state branch only fires once the query has resolved AND
// reported zero measurements; while it's in-flight the page paints
// the regular shell and the tiles fill in as their data lands.
const { data, isLoading, isFetched, isError, refetch } = useQuery({
const { data, isLoading, isFetched, isError } = useQuery({
queryKey: queryKeys.insightsComprehensive(),
queryFn: async () => {
try {
@@ -367,28 +365,29 @@ export default function InsightsPageClient() {
// Settings → Insights, reached via the top-right cog on the tab strip.
const { layout } = useInsightsLayoutQuery(isAuthenticated);
// Error branch — a transient 500 / network drop (after the query's
// retries are exhausted) settles the comprehensive query with no data.
// Without this gate the page would fall through to the "no data yet —
// add a measurement" empty-state, which reads false for a user with
// history. Surface an error + a Retry that refetches the one query,
// mirroring the <VitalsDashboard> error pattern.
if (isError) {
return (
<div data-slot="insights-overview-error">
<QueryErrorCard
description={t("insights.loadError")}
onRetry={() => refetch()}
/>
</div>
);
}
// The cycle ring takes a cell in the scores strip (and Strain's place) only
// when it would draw a dial today. With the cycle module on but no active
// cycle the ring renders nothing, and a cell reserved for it sat empty in
// the row. Same calendar read the ring makes, so no extra request.
const cycleRingVisible = isSurfaceVisible(
"overview:cycle-ring",
user?.modules,
);
const cycleRingDial = useCycleRingHasDial(
isAuthenticated && cycleRingVisible,
);
// A failed comprehensive read no longer replaces the page. The payload only
// decides the "no data yet" empty state below; every section on the page
// owns its own read and its own error row, so one failing read (#975: the
// comprehensive route refused while an AI switch was off) cannot take the
// hero, the scores and the other sections down with it. On failure the page
// simply does not claim "no data yet" and renders the sections.
// Empty-state shortcut — only paint once the comprehensive query has
// resolved AND reported zero measurements. While it's in-flight we
// fall through to the streamed shell so the user gets the hero +
// skeleton tiles inside the first paint budget.
if (!isLoading && isFetched && !data) {
if (!isLoading && isFetched && !isError && !data) {
return (
<EmptyState
icon={<TrendingUp className="size-6" />}
@@ -414,7 +413,13 @@ export default function InsightsPageClient() {
mounted && user?.username?.trim() && user.username.trim().length > 0
? user.username.split(/\s+/)[0]
: null;
const briefingPayload = advisor.payload?.dailyBriefing ?? null;
// The advisor read only fires while the briefing is available, so with it
// unavailable these stay null: the hero keeps its fallback subtitle and the
// trends row its built-in captions.
const briefingPayload = briefing.available
? (advisor.payload?.dailyBriefing ?? null)
: null;
const modules = user?.modules;
const heroStripUpdatedAt = advisor.payload?.cachedAt ?? null;
// v1.4.36 QA C2 — no `<Suspense>` wrappers below. The mother page is
@@ -471,38 +476,30 @@ export default function InsightsPageClient() {
// v1.18.0 — gate on the resolved `modules.cycle` flag (per-user toggle
// AND the operator server-wide kill-switch) so an operator-off
// instance never renders the ring nor fires its calendar read.
extraTile={
user?.modules?.cycle === true ? <CycleRingTile /> : undefined
}
// v1.39 — and only once the ring has a dial to draw (see above).
extraTile={cycleRingDial ? <CycleRingTile /> : undefined}
// v1.15.5 — when the cycle ring is shown it TAKES the Strain slot:
// hide Strain so the strip stays compact instead of growing a sixth
// tile. Strain stays visible for non-cycle accounts.
hideStrain={user?.modules?.cycle === true}
hideStrain={cycleRingDial}
/>
),
"daily-briefing": flags.briefing ? (
// Hidden, never an error card, while the briefing is unavailable for any
// reason (the operator's switch, the AI analysis opt-out, no provider,
// missing consent). A missing provider is said once, calmly, by the
// setup hint above the sections, not on this card.
"daily-briefing": briefing.available ? (
<DailyBriefing
briefing={briefingPayload}
updatedAt={heroStripUpdatedAt}
loading={advisor.isLoading}
onRegenerate={advisor.regenerate}
regenerating={advisor.isRegenerating}
// v1.15.20 — the read path reports a 422 (no provider configured)
// as its own outcome; swap the futile regenerate CTA for a quiet
// Settings → AI hint instead of an eternal "preparing" loop.
noProvider={advisor.readOutcome === "no-provider"}
// v1.18.9 (#4) — a (stale) cached briefing is shown but no provider
// is connected, so it can never refresh. Pair the relative-age
// footer with a discreet connect-provider hint. Only fires when a
// briefing is actually rendered (the empty-state path owns the
// `noProvider` branch above).
noProviderStale={briefingPayload !== null && !advisor.hasProvider}
// v1.25 — the last generation attempt failed. On a held briefing this
// adds a discreet "couldn't refresh — retry" footer hint; on an empty
// card it swaps the generic empty state for an honest "couldn't
// generate" one. Suppressed when no provider is configured (that hint
// owns the surface and a retry would be futile).
generationFailed={advisor.generationFailed && advisor.hasProvider}
// generate" one.
generationFailed={advisor.generationFailed}
// v1.25.3 — failure class points the empty-state hint at the right
// lever (raise the response timeout vs re-check the provider).
generationFailureClass={advisor.generationFailureClass}
@@ -516,27 +513,35 @@ export default function InsightsPageClient() {
trends: (
<TrendsRow
briefing={briefingPayload}
// Same module gate the tab strip applies to the Mood pill: with
// the mood module off, the trends row does not chart mood either.
hiddenMetrics={user?.modules?.mood === false ? HIDDEN_MOOD : undefined}
annotations={advisor.payload?.trendAnnotations ?? null}
loading={advisor.isLoading || advisor.isRegenerating}
annotations={
briefing.available
? (advisor.payload?.trendAnnotations ?? null)
: null
}
loading={
briefing.available && (advisor.isLoading || advisor.isRegenerating)
}
/>
),
"period-review": flags.briefing ? (
<PeriodNarrativeCard enabled={isAuthenticated} />
) : null,
// The period review always renders: its narrative is composed from the
// numbers, and the server only swaps in model-written text while the
// `periodNarrative` capability is available for this record.
"period-review": <PeriodNarrativeCard enabled={isAuthenticated} />,
// v1.15.2 — gated cycle teaser. Render only for a cycle-tracking account;
// for everyone else this is nothing (no card, no layout gap). The card
// itself stays silent until its reads resolve.
"cycle-summary": user?.cycleTrackingEnabled ? (
"cycle-summary": isSurfaceVisible("overview:cycle-summary", modules) ? (
<CycleInsightSummaryCard />
) : null,
signals: <CoincidentDeviationCard enabled={isAuthenticated} />,
"rhythm-events": <RhythmEventsCard enabled={isAuthenticated} />,
"health-status": <HealthStatusCard enabled={isAuthenticated} />,
breathing: <BreathingScreeningCard enabled={isAuthenticated} />,
"labs-changes": <LabsChangesCard enabled={isAuthenticated} />,
breathing: isSurfaceVisible("overview:breathing", modules) ? (
<BreathingScreeningCard enabled={isAuthenticated} />
) : null,
"labs-changes": isSurfaceVisible("overview:labs-changes", modules) ? (
<LabsChangesCard enabled={isAuthenticated} />
) : null,
ecg: <EcgSection enabled={isAuthenticated} limit={ECG_OVERVIEW_LIMIT} />,
};
@@ -562,11 +567,6 @@ export default function InsightsPageClient() {
briefing={briefingPayload}
updatedAt={heroStripUpdatedAt}
userName={heroGreetingName}
// v1.18.9 (#4) — the hero subtitle is the cached briefing paragraph;
// when it can never refresh (no AI provider) pair the "Generated
// <relative>" line with a discreet connect-provider hint, matching
// the briefing card footer. Only when a briefing is actually shown.
noProviderStale={briefingPayload !== null && !advisor.hasProvider}
// The Health Score lives in the band's right column and NOWHERE else
// on this page. It is deliberately not a registry section: an id a
// saved layout has never seen merges in as default-INVISIBLE
@@ -588,6 +588,10 @@ export default function InsightsPageClient() {
onScoreRetry={() => void analyticsQuery.refetch()}
/>
{/* The one place the overview mentions AI setup: only while no
provider is configured and this person could set one up. */}
<AiSetupHint />
{/* v1.15.18 — the inline "Anpassen" toggle was removed. Customising the
overview (section show/hide + order) and sorting the nav pills now
live ONLY in Settings → Insights, reached via the top-right cog on the
@@ -617,7 +621,13 @@ export default function InsightsPageClient() {
Each registry node is wrapped in a keyed Fragment so React keeps a
stable identity across a reorder. */
orderedSectionIds.map((id) => (
<Fragment key={id}>{SECTION_REGISTRY[id]}</Fragment>
<Fragment key={id}>
{SECTION_REGISTRY[id] === null ? null : (
<OverviewSectionBoundary sectionId={id}>
{SECTION_REGISTRY[id]}
</OverviewSectionBoundary>
)}
</Fragment>
))
)}
</div>
+7
View File
@@ -16,6 +16,7 @@ import {
import { LabForm } from "@/components/labs/lab-form";
import { LabList } from "@/components/labs/lab-list";
import { useAiCapability } from "@/hooks/use-ai-capability";
import { OcrReviewDialog } from "@/components/labs/ocr-review-dialog";
import {
shouldProbeOcrCapability,
@@ -64,6 +65,10 @@ export default function LabsPage() {
// explicit `false`. Every `/api/labs/*` route also enforces the gate
// server-side, so this is a UX redirect, not the security boundary.
const enabled = user?.modules?.labs !== false;
// Scanning a lab report follows the `labsOcr` capability on
// `/api/auth/me`; with only the document-reading consent missing, the scan
// stays offered and the dialog asks for it.
const labsOcr = useAiCapability("labsOcr");
const ocrCapability = useOcrCapability(
shouldProbeOcrCapability({
isAuthenticated,
@@ -71,6 +76,7 @@ export default function LabsPage() {
labsEnabled: enabled,
mounted,
ownRecord: !inSharedRecord,
labsOcr,
}),
);
@@ -216,6 +222,7 @@ export default function LabsPage() {
onOpenChange={setScanOpen}
mode={ocrCapability.data.mode}
pdfSupported={ocrCapability.data.pdfSupported}
consentRequired={labsOcr.reason === "consent_required"}
onCommitted={() => {
queryClient.invalidateQueries({
queryKey: queryKeys.labResults(),
+2 -3
View File
@@ -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);
});
});
+3 -13
View File
@@ -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.
+30
View File
@@ -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,
+142
View File
@@ -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>
);
}
+7 -12
View File
@@ -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} />;
+4 -65
View File
@@ -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
-35
View File
@@ -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>
+16 -33
View File
@@ -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" };
+5 -5
View File
@@ -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();
+7 -12
View File
@@ -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>
);
}
+24 -1
View File
@@ -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;
+25 -25
View File
@@ -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",
);
});
});
+34 -4
View File
@@ -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}
+21 -2
View File
@@ -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. */
+8 -4
View File
@@ -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
+6 -3
View File
@@ -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)
@@ -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", () => {
+148 -138
View File
@@ -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
+24 -2
View File
@@ -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",
+145 -6
View File
@@ -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>
);
}
+20 -2
View File
@@ -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
/>
</>
);
}
+9 -4
View File
@@ -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
+9 -9
View File
@@ -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
@@ -1,88 +0,0 @@
import React from "react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { DEFAULT_ASSISTANT_FLAGS, useFeatureFlags } from "../use-feature-flags";
function Probe() {
const flags = useFeatureFlags();
return <pre data-testid="flags">{JSON.stringify(flags)}</pre>;
}
function htmlEscape(s: string): string {
return s.replace(/"/g, "&quot;");
}
describe("useFeatureFlags", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns the all-on default when no QueryClient is mounted", () => {
const html = renderToStaticMarkup(<Probe />);
expect(html).toContain(htmlEscape(JSON.stringify(DEFAULT_ASSISTANT_FLAGS)));
});
it("returns the all-on default while the query is still loading", () => {
const client = new QueryClient({
defaultOptions: { queries: { retry: 0 } },
});
const html = renderToStaticMarkup(
<QueryClientProvider client={client}>
<Probe />
</QueryClientProvider>,
);
expect(html).toContain(htmlEscape(JSON.stringify(DEFAULT_ASSISTANT_FLAGS)));
});
it("returns the resolved matrix when the endpoint responds", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({
data: {
assistant: {
enabled: true,
coach: false,
briefing: true,
insightStatus: false,
documentAi: true,
},
},
}),
{ status: 200 },
),
),
);
const client = new QueryClient({
defaultOptions: { queries: { retry: 0 } },
});
// Pre-populate so the SSR pass surfaces the cached payload
// synchronously without a fetch round-trip.
client.setQueryData(["feature-flags"], {
assistant: {
enabled: true,
coach: false,
briefing: true,
insightStatus: false,
documentAi: true,
},
});
const html = renderToStaticMarkup(
<QueryClientProvider client={client}>
<Probe />
</QueryClientProvider>,
);
expect(html).toContain("&quot;coach&quot;:false");
expect(html).toContain("&quot;insightStatus&quot;:false");
});
});
+45
View File
@@ -63,3 +63,48 @@ function useAiProviderStateInner(): AiProviderState {
const { user } = useAuth();
return user?.ai?.provider ?? NO_PROVIDER_STATE;
}
/**
* The server's answer for one capability, or `null` while there is none yet
* (`/me` loading, no signed-in account, no query client). For the few places
* that act on an unavailable capability rather than just hiding: a Coach page
* that sends its visitor elsewhere must not do so on the loading frame, when
* `useAiCapability` already reads unavailable.
*/
export function useAiCapabilityAnswer(
key: AiCapabilityKey,
): AiCapabilityState | null {
const hasClient = useQueryClientMounted();
if (!hasClient) return null;
// eslint-disable-next-line react-hooks/rules-of-hooks
return useAiCapabilityAnswerInner(key);
}
function useAiCapabilityAnswerInner(
key: AiCapabilityKey,
): AiCapabilityState | null {
const { user, isLoading } = useAuth();
if (isLoading || !user) return null;
return user.ai?.capabilities?.[key] ?? UNKNOWN;
}
/**
* Every capability at once, or `null` until `/me` has answered. For the one
* surface that explains the whole set (the operator notice in Settings → AI);
* a surface that shows or hides one feature reads `useAiCapability`.
*/
export function useAiCapabilityMap(): Partial<
Record<AiCapabilityKey, AiCapabilityState>
> | null {
const hasClient = useQueryClientMounted();
if (!hasClient) return null;
// eslint-disable-next-line react-hooks/rules-of-hooks
return useAiCapabilityMapInner();
}
function useAiCapabilityMapInner(): Partial<
Record<AiCapabilityKey, AiCapabilityState>
> | null {
const { user } = useAuth();
return user?.ai?.capabilities ?? null;
}
-34
View File
@@ -1,34 +0,0 @@
"use client";
import { useAuth } from "@/hooks/use-auth";
import { useQueryClientMounted } from "@/hooks/_internal/use-query-client-safe";
/**
* v1.4.47 W3 — SSR-safe accessor for the per-user "Hide Coach" toggle.
*
* `useAuth()` calls `useQuery` directly and crashes when the caller
* SSR-renders without a `<QueryClientProvider>` (the legacy
* Coach-surface unit tests do exactly this). The cascade-test fixture
* + the dedicated disable-cascade fixture both seed a client, but the
* long-tail SSR snapshot tests (`<SuggestedPrompts>`, `<LayoutCoachFab>`,
* `<HeroStrip>` …) render the component in isolation and don't.
*
* v1.4.48 M4 — share the `<QueryClientProvider>`-mount detection with
* `useFeatureFlags()` via the `_internal/use-query-client-safe`
* helper. When no client is mounted we short-circuit to the "no
* opt-out" default; the inner hook is only invoked once the provider
* is known to be in the tree, so the conditional call is safe per the
* Rules of Hooks (the branch is stable across the component's
* lifetime).
*/
export function useDisableCoach(): boolean {
const hasClient = useQueryClientMounted();
if (!hasClient) return false;
// eslint-disable-next-line react-hooks/rules-of-hooks
return useDisableCoachInner();
}
function useDisableCoachInner(): boolean {
const { user } = useAuth();
return user?.disableCoach ?? false;
}
-104
View File
@@ -1,104 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { useQueryClientMounted } from "@/hooks/_internal/use-query-client-safe";
/**
* v1.4.31 — Client-side accessor for the operator's assistant
* feature-flag matrix. Mirrors `GET /api/feature-flags`'s response
* shape directly so callers can rely on a typed structure.
*
* The matrix gates every assistant-driven surface on the web —
* Coach + chat, Daily Briefing, per-metric status notes, reading
* documents. Deprecated: `ai` on `GET /api/auth/me` (read through
* `useAiCapability`) is the resolved answer per capability. iOS reads the same
* endpoint and uses the same matrix; the contract is locked per
* `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5.
*/
export interface AssistantFlagSet {
/** Master kill-switch — when false, every sub-flag is forced false. */
enabled: boolean;
/** Coach drawer, chat SSE, history rail, FAB. */
coach: boolean;
/** Daily Briefing card + advisor recommendations + regen icon. */
briefing: boolean;
/** Per-metric status cards on every `/insights/<metric>` sub-page. */
insightStatus: boolean;
/** Document vault reads, lab report scans, medication text extraction. */
documentAi: boolean;
}
interface FeatureFlagsPayload {
assistant: AssistantFlagSet;
}
/** All-on default — matches the v1.4.30 behaviour for fresh installs. */
export const DEFAULT_ASSISTANT_FLAGS: AssistantFlagSet = Object.freeze({
enabled: true,
coach: true,
briefing: true,
insightStatus: true,
documentAi: true,
});
async function fetchFeatureFlags(): Promise<FeatureFlagsPayload> {
// `apiFetchRaw` (no .ok throw) — this read soft-fails to the all-on
// default below instead of surfacing an error state.
const res = await apiFetchRaw("/api/feature-flags");
if (!res.ok) {
// Soft-fail: any HTTP error returns the all-on default so the
// surface continues to render. The endpoint is a non-critical
// metadata read; a 5xx must not turn into a "Coach hidden"
// false negative for the user.
return { assistant: { ...DEFAULT_ASSISTANT_FLAGS } };
}
const json = (await res.json()) as { data: FeatureFlagsPayload };
return json.data;
}
/**
* Read the operator's assistant feature-flag matrix.
*
* `staleTime: 60_000` matches the server-side cache header so the
* hot /insights mount path doesn't re-fetch the matrix on every
* remount within a minute. The hook fails open — any network error
* returns the all-on default so the user keeps seeing the surface.
*
* The hook is referenced from presentational components such as
* `<InsightStatusCard>` whose unit tests render without a
* `<QueryClientProvider>`. To keep those tests valid we delegate
* the React-Query call to a child hook that's only invoked when a
* client is mounted. The branch is stable across the component's
* lifetime — `QueryClientProvider` placement never flips between
* renders — so the conditional hook call is safe per the Rules of
* Hooks (same posture the React docs document for "different
* environments where the same component renders").
*
* v1.4.48 M4 — the SSR-mount probe is shared with `useDisableCoach`
* via `_internal/use-query-client-safe` so the eslint-disable line
* stays in exactly one place per consumer.
*/
export function useFeatureFlags(): AssistantFlagSet {
const hasClient = useQueryClientMounted();
if (!hasClient) return DEFAULT_ASSISTANT_FLAGS;
// eslint-disable-next-line react-hooks/rules-of-hooks
return useFeatureFlagsQuery();
}
function useFeatureFlagsQuery(): AssistantFlagSet {
const query = useQuery({
queryKey: queryKeys.featureFlags(),
queryFn: fetchFeatureFlags,
staleTime: 60_000,
gcTime: 300_000,
retry: 0,
});
// While loading or on error, default to all-on so the assistant
// surfaces don't flicker out and back in.
return query.data?.assistant ?? DEFAULT_ASSISTANT_FLAGS;
}
@@ -0,0 +1,47 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
/**
* Grant the consent for reading documents (`ai_extraction`) from the place
* that needs it.
*
* Sending a document, a lab report scan or a typed medication description to
* an AI service outside this server needs its own receipt. The narrow kind
* covers exactly that and never the Coach or AI analysis, so asking for it
* where a document is being read asks for no more than the reader is about to
* do. The tap on the prompt IS the consent act; the receipt records it with
* its source, the same way the web settings record a full grant.
*
* On success `/api/auth/me` is refetched, because the capabilities it
* publishes (`documentAi`, `labsOcr`, `medicationExtract`) are what every
* reading surface renders from.
*/
export function useGrantDocumentReadingConsent() {
const queryClient = useQueryClient();
return useMutation({
mutationKey: queryKeys.aiExtractionConsentGrant(),
mutationFn: async () => {
const signedAt = new Date().toISOString();
return apiPost("/api/consent/ai", {
kind: "ai_extraction",
signedAt,
artefact: JSON.stringify({
source: "web",
kind: "ai_extraction",
grantedAt: signedAt,
note: "In-app affirmative consent to reading documents, granted via the web client.",
}),
});
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
void queryClient.invalidateQueries({
queryKey: queryKeys.aiConsentReceipt("ai_extraction"),
});
},
});
}
+14 -3
View File
@@ -2,6 +2,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAiCapability } from "@/hooks/use-ai-capability";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry";
@@ -295,6 +296,10 @@ const QUERY_KEY_FACTORY: Record<
*/
export function useInsightStatus(metric: InsightStatusMetric) {
const { isAuthenticated } = useAuth();
// Status notes are model-written: no read while the `statusText`
// capability is unavailable (the card is hidden then anyway). Unavailable
// while `/me` loads, so the first paint fires nothing.
const statusText = useAiCapability("statusText");
const { locale } = useTranslations();
const queryKey = QUERY_KEY_FACTORY[metric](locale);
@@ -305,7 +310,7 @@ export function useInsightStatus(metric: InsightStatusMetric) {
`/api/insights/${metric}-status?locale=${locale}`,
signal,
),
enabled: isAuthenticated,
enabled: isAuthenticated && statusText.available,
staleTime: 60 * 1000,
// v1.4.28 FB-D2 — the status route returns a deterministic envelope on
// a miss/timeout. Retrying inline re-fires work and lengthens the
@@ -359,6 +364,7 @@ export function useInsightMetricStatus(
enabled = true,
) {
const { isAuthenticated } = useAuth();
const statusText = useAiCapability("statusText");
const { locale } = useTranslations();
const queryKey = queryKeys.insightsMetricStatus(metric, locale);
@@ -371,7 +377,7 @@ export function useInsightMetricStatus(
)}&locale=${locale}`,
signal,
),
enabled: isAuthenticated && enabled,
enabled: isAuthenticated && enabled && statusText.available,
staleTime: 60 * 1000,
retry: 0,
refetchInterval: (query) =>
@@ -409,6 +415,7 @@ export function useInsightBiomarkerAssessment(
enabled = true,
) {
const { isAuthenticated } = useAuth();
const statusText = useAiCapability("statusText");
const { locale } = useTranslations();
const queryKey = queryKeys.insightsBiomarkerAssessment(biomarkerId, locale);
@@ -421,7 +428,11 @@ export function useInsightBiomarkerAssessment(
)}&locale=${locale}`,
signal,
),
enabled: isAuthenticated && enabled && biomarkerId.length > 0,
enabled:
isAuthenticated &&
enabled &&
statusText.available &&
biomarkerId.length > 0,
staleTime: 60 * 1000,
retry: 0,
refetchInterval: (query) =>
+1 -1
View File
@@ -7,7 +7,7 @@ import type { ModuleKey } from "@/lib/modules/registry";
/**
* v1.18.0 R4 — SSR-safe accessor for a per-account module flag.
*
* Mirrors `useDisableCoach()`: `useAuth()` calls `useQuery` directly and
* `useAuth()` calls `useQuery` directly and
* crashes when a legacy presentational component (`<HeroStrip>`, the
* isolated score-card snapshots, …) renders without a
* `<QueryClientProvider>`. We gate the inner hook on
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { useMemo } from "react";
import { useAiCapability } from "@/hooks/use-ai-capability";
import { useAuth } from "@/hooks/use-auth";
import type { SurfaceModuleMap } from "@/lib/modules/surface";
/**
* The module map the navigation filters by, with the Coach entry following
* the `coach` AI capability.
*
* `modules.coach` answers the operator's switch and the person's Hide Coach,
* but not a missing provider or missing consent, so on its own it would keep
* a Coach entry in the nav that opens a Coach unable to answer. The
* capability answers all of them, and every other Coach entry point (the
* floating button, the launch buttons, the Coach pages) already reads it.
* The sidebar and the bottom bar both take their map from here so the two
* cannot disagree.
*/
export function useNavModules(): SurfaceModuleMap | undefined {
const { user } = useAuth();
const coach = useAiCapability("coach");
const modules = user?.modules;
return useMemo(
() =>
modules === undefined
? undefined
: { ...modules, coach: modules.coach !== false && coach.available },
[modules, coach.available],
);
}
+7 -1
View File
@@ -70,6 +70,12 @@ export interface UseWorkoutsOptions {
since?: string;
/** Optional sport-type filter. Pass through the canonical string. */
sportType?: string;
/**
* Whether to read at all. A probe on a surface that shows workouts only
* while the Workouts module is on passes the module answer here, so a
* switched-off module is never asked for rows. Defaults to true.
*/
enabled?: boolean;
}
async function fetchWorkouts(
@@ -108,7 +114,7 @@ export function useWorkouts(opts: UseWorkoutsOptions = {}): UseWorkoutsResult {
sportType: opts.sportType,
}),
queryFn: () => fetchWorkouts(opts),
enabled: isAuthenticated,
enabled: isAuthenticated && opts.enabled !== false,
staleTime: 60 * 1000,
});
@@ -1,404 +0,0 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
import {
DEFAULT_ASSISTANT_FLAGS,
type AssistantFlagSet,
} from "@/hooks/use-feature-flags";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
// v1.4.38 W-C M-5 — capture the original `useFeatureFlags` and route
// the spy through it so the gate-fired assertion below can verify the
// hook ran with the operator-disabled matrix. Setting `vi.mock()` here
// (instead of `vi.spyOn`) is required because the components import the
// hook at module load; a runtime `spyOn` would never intercept the call.
const featureFlagsSpy = vi.fn<() => AssistantFlagSet>(
() => DEFAULT_ASSISTANT_FLAGS,
);
vi.mock("@/hooks/use-feature-flags", async () => {
const actual = await vi.importActual<
typeof import("@/hooks/use-feature-flags")
>("@/hooks/use-feature-flags");
return {
...actual,
useFeatureFlags: () => featureFlagsSpy(),
};
});
// v1.16.1 — the nudge-driven `<LayoutCoachFab>` reads the app router
// (push + pathname); SSR test renders have no router context.
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/insights",
}));
/**
* v1.4.37 W5 — Coach disable cascade invariant.
*
* The maintainer directive: when the operator turns the global Coach flag off,
* every Coach affordance on every surface must vanish — no
* grey-out, no error, no inert button, no DOM trace. The web app
* gates each surface via `useFeatureFlags()` and a `flags.coach`
* short-circuit; the invariant below pins the contract so a future
* surface addition can't silently leak the affordance.
*
* How to extend: when a new Coach-bearing surface lands (button,
* drawer, chip strip, FAB, inline pill), add an entry to the
* `COACH_SURFACES` fixture below and bump the `expect(... .length)`
* count in the trailing sync-check. The contract per surface is
* "renders nothing measurable in the SSR output when the Coach flag
* is off"; surfaces that need data-slot probes should expose a
* stable `data-slot="coach-*"` attribute the test can grep.
*
* The hook layer is covered by `src/hooks/__tests__/
* use-feature-flags.test.tsx`; this file is the integration-style
* contract that walks each surface and asserts the gate is present.
*/
/**
* Build a QueryClient pre-seeded with the operator's flag matrix so
* every `useFeatureFlags()` consumer reads the supplied set
* synchronously during the SSR pass (no fetch round-trip required).
*/
function buildClient(flags: Partial<AssistantFlagSet>): QueryClient {
const client = new QueryClient({
defaultOptions: { queries: { retry: 0 } },
});
client.setQueryData(["feature-flags"], {
assistant: {
enabled: true,
coach: true,
briefing: true,
insightStatus: true,
documentAi: true,
...flags,
},
});
return client;
}
function renderWithFlags(node: React.ReactNode, coach: boolean): string {
// v1.4.38 W-C M-5 — drive the mocked `useFeatureFlags` from the same
// matrix the QueryClient is seeded with so the gate-call assertion at
// the bottom of the file can read the resolved value. Keeping the
// QueryClient seed in place preserves the original render path for
// any consumer that bypasses the hook.
const resolved: AssistantFlagSet = {
...DEFAULT_ASSISTANT_FLAGS,
coach,
};
featureFlagsSpy.mockImplementation(() => resolved);
const client = buildClient({ coach });
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
// ────────────────────────────────────────────────────────────────────
// Coach-bearing surface fixture.
//
// Each entry mounts the surface in isolation, supplies the minimum
// non-Coach scaffolding the component needs (CoachLaunchProvider, …)
// and exposes a stable substring that proves the surface painted. The
// substring should be unique enough that a "did not render" check is
// meaningful — typically the surface's `data-slot="coach-*"`
// attribute or its localised label.
//
// IMPORTANT: when adding a new Coach surface to the codebase, add a
// matching entry here. The test below walks every entry and asserts
// the surface vanishes when the operator's global Coach flag is off.
// ────────────────────────────────────────────────────────────────────
interface CoachSurface {
/** Human-readable description that surfaces in the test output. */
name: string;
/** Mounts the surface in isolation. Receives no props. */
mount: () => React.ReactNode;
/**
* Substring that proves the surface painted when the flag is ON.
* Empty string means "the surface's SSR shape is intentionally
* blank even with the flag on" (e.g. lazy-loaded subtrees); the
* positive-case test is skipped while the negative-case
* `coach-*` slot grep still pins the gate.
*/
proofWhenOn: string;
}
// v1.18.7 — the HeroStrip "Ask the coach" action button + the in-band
// SuggestedPrompts chip strip were removed from the overview hero, and the
// strip's component has since been deleted along with the rest of the
// unmounted insights cluster. The Coach is the bottom-right drawer; the
// launcher surfaces below carry the flag cascade.
const COACH_SURFACES: CoachSurface[] = [
{
name: "CoachLaunchButton inline pill",
mount: () => (
// The button only renders inside <CoachLaunchProvider>; wrap so
// the Coach-flag-off branch is the dispositive guard.
<CoachLaunchProvider>
<CoachLaunchButton />
</CoachLaunchProvider>
),
proofWhenOn: 'data-slot="coach-launch-inline"',
},
{
name: "LayoutCoachFab sticky mobile button",
mount: () => (
<CoachLaunchProvider>
<LayoutCoachFab />
</CoachLaunchProvider>
),
// v1.16.1 — the FAB is nudge-driven: it renders nothing until the
// nudge-status query resolves with an unseen nudge, so its SSR
// shape is empty even when the flag is on. The negative `coach-*`
// grep + the gate-fired spy assertion still pin the off state.
proofWhenOn: "",
},
{
name: "LayoutCoachMount drawer subtree",
mount: () => (
<CoachLaunchProvider>
<LayoutCoachMount />
</CoachLaunchProvider>
),
// The drawer is lazy-loaded via next/dynamic and SSRs to nothing
// even when the flag is on; the contract here is "no Coach-marked
// mount point ever paints when the flag is off". Skip the proof
// check; the negative `coach-*` slot grep still pins the gate, and
// the explicit spy assertion in `LayoutCoachMount SSR-proof spy`
// below proves the gate actually fired during the off render.
proofWhenOn: "",
},
];
describe("Coach disable cascade invariant", () => {
// Silence the dev-only "should not be used during SSR" warning some
// components emit when their effects would normally mount.
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(console, "warn").mockImplementation(() => undefined);
// v1.4.38 W-C M-5 — reset the call history so each iteration of
// the surface loop sees a clean slate when it asserts the gate
// fired during the off render.
featureFlagsSpy.mockClear();
featureFlagsSpy.mockImplementation(() => DEFAULT_ASSISTANT_FLAGS);
});
for (const surface of COACH_SURFACES) {
it(`${surface.name} — paints when the Coach flag is on`, () => {
// Skip the positive proof for surfaces whose SSR shape is
// intentionally empty (lazy-loaded drawer subtrees). The
// negative assertion below still pins the gate.
if (!surface.proofWhenOn) return;
const html = renderWithFlags(surface.mount(), true);
expect(html).toContain(surface.proofWhenOn);
});
it(`${surface.name} — vanishes when the Coach flag is off`, () => {
const html = renderWithFlags(surface.mount(), false);
if (surface.proofWhenOn) {
expect(html).not.toContain(surface.proofWhenOn);
}
// Every Coach-bearing surface in the app marks its mount point
// with a `coach-` slot. Pin the negative shape against the
// shared prefix so a surface that grows a new `coach-*` slot
// without a gate fails this test by default.
expect(html).not.toMatch(/data-slot="coach-[a-z]/);
// The suggested-prompts strip uses an `insights-suggested-prompts`
// slot (legacy naming pre-dates the `coach-` convention); pin
// it explicitly so the gate can't regress.
expect(html).not.toContain('data-slot="insights-suggested-prompts"');
// Same story for the hero strip's coach action button + prompts
// wrapper.
expect(html).not.toContain(
'data-slot="insights-hero-strip-action-coach"',
);
expect(html).not.toContain('data-slot="insights-hero-strip-prompts"');
// v1.4.38 W-C M-5 — `proofWhenOn: ""` surfaces (the lazy-loaded
// `LayoutCoachMount` drawer subtree) trivially pass the substring
// grep because their SSR shape is empty even when the gate is on.
// The spy assertion below proves the gate actually fired with the
// operator-disabled matrix, so a regression that removes the
// `if (!flags.coach) return null` short-circuit fails this test
// instead of slipping through under cover of the empty SSR shape.
expect(featureFlagsSpy).toHaveBeenCalled();
const lastCall = featureFlagsSpy.mock.results.at(-1);
expect(lastCall?.value.coach).toBe(false);
});
}
// ──────────────────────────────────────────────────────────────────
// v1.4.38 W-C H4 — grep-based discovery of every `flags.coach`
// call site in the codebase.
//
// The fixture-count check below pins the surfaces the fixture mounts
// directly. A future contributor who lands a new Coach mount on (e.g.)
// `/insights/blood-pressure/page.tsx` would not trip the surface-count
// check at all — the new site is on a sub-page and would silently leak.
//
// The walk below scans `src/` for every `flags.coach` occurrence and
// requires each path to appear in the explicit `KNOWN_COACH_GATE_SITES`
// allowlist. Add a new entry every time you intentionally gate a new
// render path on the Coach flag; the failure message names the
// unaccounted-for paths so the fix is one search-and-add.
// ──────────────────────────────────────────────────────────────────
/**
* Every file in `src/` that legitimately reads `flags.coach`. Lives
* here (next to the cascade fixture) so a contributor extending the
* fixture sees the allowlist in the same review.
*
* Paths are POSIX-style relative to the repo root.
*/
const KNOWN_COACH_GATE_SITES: ReadonlyArray<string> = [
// Coach-bearing surfaces mounted directly by the cascade fixture.
"src/components/insights/coach-launch-button.tsx",
// v1.21.0 (C4 H2) — the reverse-direction "Ask the Coach about this"
// card affordance gates on `flags.coach` (operator master) like the
// launch button, returning null when the operator turns the Coach off.
"src/components/insights/ask-coach-action.tsx",
// v1.18.7 — hero-strip.tsx no longer reads `flags.coach`: its coach
// action button + suggested-prompt strip were removed from the band.
"src/components/insights/layout-coach-fab.tsx",
"src/components/insights/layout-coach-mount.tsx",
// v1.12.0 — the full-page Coach route gates on `flags.coach` and
// redirects to `/insights` when the operator master flag is off.
// v1.18.0 — moved to the standalone top-level `/coach` route.
"src/app/coach/page-client.tsx",
// v1.21.4 — the dedicated conversation-history page gates on
// `flags.coach` like the main Coach route and renders unavailable
// when the operator master flag is off.
"src/app/coach/conversations/page.tsx",
// The plans management page mirrors the conversation-history page's
// gate: operator master flag off redirects back to `/insights`.
"src/app/coach/plans/page.tsx",
// v1.15.20 — the proactive Coach-nudge cron short-circuits on
// `flags.coach` (gate 1) so an operator kill-switch also silences
// the nudges. Server-side cron, not a render path — the cascade
// fixture cannot mount it, so the allowlist entry is the pin.
"src/lib/jobs/coach-nudge.ts",
// The Modules hub renders a live Coach on/off switch. It reads
// `flags.coach` (the operator assistant master flag) so the switch goes
// disabled + "disabled server-wide" when the operator killed Coach — a
// per-user toggle cannot re-enable it. Distinct from the render-path
// gates above: here the flag drives the switch's disabled state, not a
// null-return.
"src/components/settings/modules-section.tsx",
];
/**
* Walk every TS / TSX file under `src/`, excluding test trees, and
* collect the relative paths that contain a `flags.coach` token. The
* grep is intentionally simple — any literal occurrence counts so a
* contributor who paraphrases the read (`const c = flags.coach`)
* still trips this test.
*/
function findCoachFlagSites(): string[] {
const srcRoot = resolve(__dirname, "..", "..", "..");
const hits: string[] = [];
const repoRoot = resolve(srcRoot, "..");
function walk(dir: string): void {
const entries = readdirSync(dir);
for (const entry of entries) {
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) {
if (entry === "node_modules" || entry === ".next") continue;
// Skip every `__tests__/` subtree — the discovery target is
// the production gate, not the assertions about it.
if (entry === "__tests__") continue;
// v1.4.47 W3 — skip the Prisma client output. The generator
// embeds the entire `prisma/schema.prisma` source into
// `internal/class.ts` as a JSON-escaped `inlineSchema`
// string, so any literal `flags.coach` mention in a schema
// doc-comment leaks through here and trips the orphan check.
// Generated derivatives are never the gate site we want to
// pin; the production component file is.
if (entry === "generated") continue;
walk(full);
continue;
}
if (!/\.(tsx|ts)$/.test(entry)) continue;
if (/\.test\.(tsx|ts)$/.test(entry)) continue;
const text = readFileSync(full, "utf8");
// Match `flags.coach` on lines that are NOT pure comments. A
// hit on a JSDoc / `//` line is documentation, not a gate, and
// would make the allowlist a maintenance burden every time
// someone references the gate in a comment.
const isRealGate = text.split("\n").some((line) => {
if (!line.includes("flags.coach")) return false;
const trimmed = line.trim();
if (trimmed.startsWith("//")) return false;
if (trimmed.startsWith("*")) return false;
return true;
});
if (isRealGate) {
// POSIX-style path relative to the repo root so the failure
// message stays portable across contributors' checkout
// directories.
hits.push(relative(repoRoot, full).split(/[\\/]/).join("/"));
}
}
}
walk(srcRoot);
return hits.sort();
}
it("grep-based discovery pins every `flags.coach` call site", () => {
const discovered = findCoachFlagSites();
const allowlist = new Set(KNOWN_COACH_GATE_SITES);
const orphans = discovered.filter((path) => !allowlist.has(path));
expect(
orphans,
[
"Found `flags.coach` in file(s) not on the KNOWN_COACH_GATE_SITES",
"allowlist (src/lib/feature-flags/__tests__/coach-cascade.test.tsx).",
"Add a fixture entry (or a sibling invariant) for the new gate,",
"then add the path to the allowlist. Orphans:",
...orphans.map((p) => ` - ${p}`),
].join("\n"),
).toEqual([]);
// Sanity check the other direction — an allowlist entry that no
// longer references `flags.coach` is dead documentation and
// should be pruned in the same PR that removes the gate.
const discoveredSet = new Set(discovered);
const stale = KNOWN_COACH_GATE_SITES.filter(
(path) => !discoveredSet.has(path),
);
expect(
stale,
[
"KNOWN_COACH_GATE_SITES references file(s) that no longer use",
"`flags.coach`. Remove the stale allowlist entry:",
...stale.map((p) => ` - ${p}`),
].join("\n"),
).toEqual([]);
});
it("fixture stays in sync with the Coach gate call sites", () => {
// Counts the Coach-bearing surfaces the fixture knows about. If a
// contributor adds a new `useFeatureFlags()` + `flags.coach` gate
// to a render path, the count below must move and this assertion
// forces them to revisit the fixture. The number tracks the
// surfaces the fixture mounts directly; the grep-based discovery
// test above pins every other `flags.coach` call site.
// v1.18.7 — dropped from 6 to 4: the two HeroStrip coach surfaces
// (action button + suggested-prompt strip) were removed from the band.
expect(COACH_SURFACES.length).toBe(3);
});
});
@@ -1,431 +0,0 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
import {
DEFAULT_ASSISTANT_FLAGS,
type AssistantFlagSet,
} from "@/hooks/use-feature-flags";
import type { AuthUser } from "@/hooks/use-auth";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
/**
* v1.4.47 W3 — per-user "Hide Coach" opt-out invariant.
*
* The Coach disable cascade (`coach-cascade.test.tsx`) pins the
* operator-level `flags.coach` gate. This sibling fixture pins the
* per-user `disableCoach` gate that sits BELOW the operator flag —
* either gate being off must hide every Coach affordance with no
* grey-out, no error, no inert button, no DOM trace.
*
* Mocking strategy mirrors the cascade fixture: spy `useFeatureFlags`
* to drive the operator matrix synchronously during SSR, spy
* `useAuth` to drive the per-user `disableCoach` field. Both spies
* are reset between iterations so a regression in either gate trips
* the invariant in isolation.
*/
const featureFlagsSpy = vi.fn<() => AssistantFlagSet>(
() => DEFAULT_ASSISTANT_FLAGS,
);
vi.mock("@/hooks/use-feature-flags", async () => {
const actual = await vi.importActual<
typeof import("@/hooks/use-feature-flags")
>("@/hooks/use-feature-flags");
return {
...actual,
useFeatureFlags: () => featureFlagsSpy(),
};
});
const authSpy = vi.fn<
() => { user: AuthUser | null; isAuthenticated: boolean }
>(() => ({ user: null, isAuthenticated: false }));
vi.mock("@/hooks/use-auth", async () => {
const actual =
await vi.importActual<typeof import("@/hooks/use-auth")>(
"@/hooks/use-auth",
);
return {
...actual,
useAuth: () => authSpy(),
};
});
// The Coach gates read user.disableCoach through `useDisableCoach`,
// which itself calls `useAuth()`. Mocking `useDisableCoach` directly
// would bypass the useAuth call site and make the spy assertion
// below trivially fail; instead we let the real `useDisableCoach`
// pass through and capture the underlying `useAuth` calls.
// v1.16.1 — the nudge-driven `<LayoutCoachFab>` reads the app router
// (push + pathname); SSR test renders have no router context.
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/insights",
}));
function buildUser(disableCoach: boolean): AuthUser {
// Minimal `AuthUser` shape — the Coach gates only read
// `disableCoach`, but TypeScript requires the full interface to
// satisfy the mock's return type. Everything else is filler.
return {
id: "test-user-id",
username: "test",
email: null,
role: "USER",
heightCm: null,
dateOfBirth: null,
gender: null,
timezone: "Europe/Berlin",
onboardingCompletedAt: null,
onboardingTourCompleted: true,
onboardingTourProgress: null,
avatarUrl: null,
glucoseUnit: null,
unitPreference: "metric",
timeFormat: "AUTO",
dateFormat: "AUTO",
disableCoach,
fullName: null,
insurerName: null,
insurerIkNumber: null,
insuranceNumber: null,
lastReportPracticeName: null,
reportSelection: null,
cycleTrackingEnabled: false,
modules: {},
};
}
function buildClient(): QueryClient {
return new QueryClient({
defaultOptions: { queries: { retry: 0 } },
});
}
function renderWithDisableCoach(
node: React.ReactNode,
disableCoach: boolean,
coach: boolean = true,
): string {
featureFlagsSpy.mockImplementation(() => ({
...DEFAULT_ASSISTANT_FLAGS,
coach,
}));
authSpy.mockImplementation(() => ({
user: buildUser(disableCoach),
isAuthenticated: true,
}));
const client = buildClient();
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
interface DisableCoachSurface {
name: string;
mount: () => React.ReactNode;
/**
* Substring that proves the surface painted. Empty string means
* "the surface SSR-renders to nothing even when on" (lazy-loaded
* subtrees) and the negative assertion is the dispositive check.
*/
proofWhenVisible: string;
}
const DISABLE_COACH_SURFACES: DisableCoachSurface[] = [
{
name: "LayoutCoachFab",
mount: () => (
<CoachLaunchProvider>
<LayoutCoachFab />
</CoachLaunchProvider>
),
// v1.16.1 — nudge-driven: SSR shape is empty even when visible
// (the bubble waits for the nudge-status query). Negative
// assertion still pins the gate via the `coach-*` slot grep.
proofWhenVisible: "",
},
{
name: "LayoutCoachMount",
mount: () => (
<CoachLaunchProvider>
<LayoutCoachMount />
</CoachLaunchProvider>
),
// Lazy-loaded — SSR shape is empty even when visible. Negative
// assertion still pins the gate via the `coach-*` slot grep.
proofWhenVisible: "",
},
{
name: "CoachLaunchButton inline pill",
mount: () => (
<CoachLaunchProvider>
<CoachLaunchButton />
</CoachLaunchProvider>
),
proofWhenVisible: 'data-slot="coach-launch-inline"',
},
// v1.18.7 — the HeroStrip "Ask the coach" action button and the
// SuggestedPrompts chip strip were removed from the overview hero, and the
// strip's component has now been deleted with the rest of the unmounted
// insights cluster. The Coach is the bottom-right drawer; the launcher
// surfaces above carry the per-user disable gate.
];
describe("Coach per-user disableCoach invariant", () => {
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(console, "warn").mockImplementation(() => undefined);
featureFlagsSpy.mockClear();
authSpy.mockClear();
featureFlagsSpy.mockImplementation(() => DEFAULT_ASSISTANT_FLAGS);
authSpy.mockImplementation(() => ({
user: buildUser(false),
isAuthenticated: true,
}));
});
for (const surface of DISABLE_COACH_SURFACES) {
it(`${surface.name} — paints when user.disableCoach is false`, () => {
if (!surface.proofWhenVisible) return;
const html = renderWithDisableCoach(surface.mount(), false);
expect(html).toContain(surface.proofWhenVisible);
});
it(`${surface.name} — vanishes when user.disableCoach is true`, () => {
const html = renderWithDisableCoach(surface.mount(), true);
if (surface.proofWhenVisible) {
expect(html).not.toContain(surface.proofWhenVisible);
}
// No Coach-marked mount point may paint when the per-user gate
// is on. Pin the negative shape against the shared prefix so a
// future surface that grows a new `coach-*` slot without the
// disableCoach gate fails this test by default.
expect(html).not.toMatch(/data-slot="coach-[a-z]/);
expect(html).not.toContain('data-slot="insights-suggested-prompts"');
expect(html).not.toContain(
'data-slot="insights-hero-strip-action-coach"',
);
expect(html).not.toContain('data-slot="insights-hero-strip-prompts"');
});
it(`${surface.name} — also vanishes when flags.coach is false (regression pin)`, () => {
// The legacy operator-level gate must keep working in the
// presence of the new per-user gate. This guards against a
// regression where a contributor "simplifies" the two checks
// into one and accidentally drops the operator branch.
const html = renderWithDisableCoach(surface.mount(), false, false);
if (surface.proofWhenVisible) {
expect(html).not.toContain(surface.proofWhenVisible);
}
expect(html).not.toMatch(/data-slot="coach-[a-z]/);
});
}
it("`useAuth` gate is consulted on every Coach surface", () => {
// A `disableCoach: true` render must trip the `useAuth` spy at
// least once. Otherwise a future regression that drops the
// `useAuth()` call site silently leaks the affordance.
for (const surface of DISABLE_COACH_SURFACES) {
authSpy.mockClear();
renderWithDisableCoach(surface.mount(), true);
expect(
authSpy,
`${surface.name} did not consult useAuth() during render`,
).toHaveBeenCalled();
}
});
// ──────────────────────────────────────────────────────────────────
// v1.4.48 H2 — grep-based discovery of every `useDisableCoach` /
// `user.disableCoach` call site in the codebase.
//
// Mirrors the `flags.coach` discovery walk in `coach-cascade.test.tsx`:
// the surface fixture above pins the surfaces this file mounts
// directly. Cross-cut gates (Settings → Insights toggle, `/api/auth/me`
// response shape, the disable-coach PATCH route) sit OUTSIDE the
// fixture and are owned by sibling invariants or by route-level tests.
//
// The walk below scans `src/` for every `useDisableCoach` /
// `user.disableCoach` / `user?.disableCoach` occurrence and requires
// each path to appear in the explicit `KNOWN_DISABLE_COACH_GATE_SITES`
// allowlist. Add a new entry every time you intentionally read the
// per-user disable flag in a new path; the failure message names the
// unaccounted-for paths so the fix is one search-and-add.
// ──────────────────────────────────────────────────────────────────
/**
* Every file in `src/` that legitimately reads `useDisableCoach` or
* `user.disableCoach`. Lives next to the disable-cascade fixture so a
* contributor extending the fixture sees the allowlist in the same
* review.
*
* Paths are POSIX-style relative to the repo root.
*/
const KNOWN_DISABLE_COACH_GATE_SITES: ReadonlyArray<string> = [
// Coach-bearing surfaces mounted directly by the disable-cascade
// fixture above; each one calls `useDisableCoach()` and short-
// circuits to `null` when the per-user flag is on.
"src/components/insights/coach-launch-button.tsx",
// v1.21.0 (C4 H2) — the reverse-direction "Ask the Coach about this"
// card affordance mirrors the launch-button gate: operator master flag
// OR per-user opt-out short-circuits it to `null` so a card never paints
// a dead Coach control.
"src/components/insights/ask-coach-action.tsx",
// v1.18.7 — hero-strip.tsx no longer gates on the Coach flag: its
// action button + suggested-prompt strip were removed, so it dropped
// the `useDisableCoach` call entirely.
"src/components/insights/layout-coach-fab.tsx",
"src/components/insights/layout-coach-mount.tsx",
// v1.12.0 — the full-page Coach route mirrors the launch-button /
// FAB gate: operator master flag OR per-user opt-out redirects the
// page back to `/insights` instead of painting a dead chat shell.
// v1.18.0 — moved to the standalone top-level `/coach` route.
// v1.30.x — split into an RSC prefetch wrapper (`page.tsx`) + the client
// leaf (`page-client.tsx`, the `useDisableCoach` render-path gate). The
// RSC wrapper now asks the `coach` capability, which folds the opt-out in,
// so only the client leaf reads the flag directly.
"src/app/coach/page-client.tsx",
// v1.21.4 — the dedicated conversation-history page mirrors the
// Coach route gate: operator master flag OR per-user opt-out marks
// the page unavailable instead of painting a dead history shell.
"src/app/coach/conversations/page.tsx",
// The plans management page mirrors the same gate: operator master
// flag OR per-user opt-out redirects instead of a dead plans shell.
"src/app/coach/plans/page.tsx",
// The hook itself + its `useAuth`-backed reader.
"src/hooks/use-disable-coach.ts",
// Cross-cut gates owned by sibling invariants / route tests.
// - `/api/auth/me` ships the flag in the session payload so the
// client hooks can read it (`useAuth → useDisableCoach`). The
// sibling `/api/auth/me/disable-coach` PATCH endpoint that
// writes the flag does NOT read `user.disableCoach` (it reads
// `body.disableCoach`) and is intentionally not listed here.
"src/app/api/auth/me/route.ts",
// - Settings → Coach section: the toggle card reads + writes the
// flag; the section shell reads it to gate the Coach sub-cards.
// v1.18.0 — moved out of the AI/Assistent section into its own
// dedicated Coach settings entry.
"src/components/settings/ai/disable-coach-card.tsx",
"src/components/settings/coach-section.tsx",
// The dashboard snapshot no longer reads the flag: hiding the Coach
// hides the Coach, and the daily briefing follows its own `briefing`
// capability, applied per read in `snapshot-read.ts`.
// v1.18.0 — the module enable/disable gate delegates the `coach`
// module to the SAME two-layer source of truth (`user.disableCoach`
// AND the operator assistant master flag) rather than owning a
// second copy in `modulePreferencesJson`. Covered by the coach-
// delegation cases in `src/lib/modules/__tests__/gate.test.ts`.
"src/lib/modules/gate.ts",
// The Modules hub Coach row renders a live switch bound to the SAME
// `user.disableCoach` source of truth: `checked` is `!disableCoach` and a
// flip writes the inverted flag via `PATCH /api/auth/me/disable-coach`.
// Reads the flag off `useAuth().user` (no second copy). Covered by the
// delegated-coach cases in
// `src/components/settings/__tests__/modules-section.test.tsx`.
"src/components/settings/modules-section.tsx",
];
/**
* Walk every TS / TSX file under `src/`, excluding test trees + the
* Prisma client output, and collect the relative paths that
* legitimately reference `useDisableCoach` or `user.disableCoach`.
* Any literal occurrence on a non-comment line counts so a
* contributor who paraphrases the read still trips this test.
*/
function findDisableCoachSites(): string[] {
const srcRoot = resolve(__dirname, "..", "..", "..");
const repoRoot = resolve(srcRoot, "..");
const hits: string[] = [];
function isRealGateLine(line: string): boolean {
// Trim once so leading whitespace doesn't fool the comment check.
const trimmed = line.trim();
if (trimmed.startsWith("//")) return false;
if (trimmed.startsWith("*")) return false;
// `useDisableCoach` covers both the hook import + the hook call.
if (line.includes("useDisableCoach")) return true;
// `user.disableCoach` / `user?.disableCoach` — the canonical
// per-user gate read on the auth user object. Strict match on
// `user` so we don't grab unrelated `disableCoach` strings (route
// body keys, i18n message ids, …).
if (/\buser\??\.disableCoach\b/.test(line)) return true;
return false;
}
function walk(dir: string): void {
const entries = readdirSync(dir);
for (const entry of entries) {
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) {
if (entry === "node_modules" || entry === ".next") continue;
if (entry === "__tests__") continue;
// Skip the Prisma client output — the generator embeds the
// entire `prisma/schema.prisma` source as a JSON-escaped
// `inlineSchema` string and any schema doc-comment that
// mentions `disableCoach` would otherwise leak through here.
if (entry === "generated") continue;
walk(full);
continue;
}
if (!/\.(tsx|ts)$/.test(entry)) continue;
if (/\.test\.(tsx|ts)$/.test(entry)) continue;
const text = readFileSync(full, "utf8");
if (text.split("\n").some(isRealGateLine)) {
hits.push(relative(repoRoot, full).split(/[\\/]/).join("/"));
}
}
}
walk(srcRoot);
return hits.sort();
}
it("grep-based discovery pins every `useDisableCoach` / `user.disableCoach` call site", () => {
const discovered = findDisableCoachSites();
const allowlist = new Set(KNOWN_DISABLE_COACH_GATE_SITES);
const orphans = discovered.filter((path) => !allowlist.has(path));
expect(
orphans,
[
"Found `useDisableCoach` / `user.disableCoach` in file(s) not on",
"the KNOWN_DISABLE_COACH_GATE_SITES allowlist",
"(src/lib/feature-flags/__tests__/coach-user-disable.test.tsx).",
"Add a fixture entry (or a sibling invariant) for the new gate,",
"then add the path to the allowlist. Orphans:",
...orphans.map((p) => ` - ${p}`),
].join("\n"),
).toEqual([]);
// Sanity check the other direction — an allowlist entry that no
// longer references the gate is dead documentation and should be
// pruned in the same PR that removes the read.
const discoveredSet = new Set(discovered);
const stale = KNOWN_DISABLE_COACH_GATE_SITES.filter(
(path) => !discoveredSet.has(path),
);
expect(
stale,
[
"KNOWN_DISABLE_COACH_GATE_SITES references file(s) that no longer",
"use `useDisableCoach` / `user.disableCoach`. Remove the stale",
"allowlist entry:",
...stale.map((p) => ` - ${p}`),
].join("\n"),
).toEqual([]);
});
});
@@ -88,7 +88,6 @@ const RETIRED_SWITCHES: RetiredSwitch[] = [
*/
const PLUMBING = [
"src/lib/feature-flags/index.ts",
"src/hooks/use-feature-flags.ts",
"src/components/admin/assistant-section.tsx",
"src/app/api/feature-flags/route.ts",
"src/app/api/admin/settings/assistant-flags/route.ts",
+14 -3
View File
@@ -56,6 +56,7 @@ function inputs(
dataSourceConnected: false,
notificationsConfigured: false,
insightsConfigured: false,
aiConfigurable: true,
dismissedIds: new Set<ChecklistItemId>(),
upcomingVisitCount: 0,
managedProfileCount: 0,
@@ -94,13 +95,22 @@ describe("buildChecklist", () => {
it("flags insights done once any provider can serve the user", () => {
const off = buildChecklist(inputs({ insightsConfigured: false }));
expect(off.find((i) => i.id === "insights")?.done).toBe(false);
// `insightsConfigured` is derived from aiAvailable, which is true for a
// personal key, a local model, an OAuth sign-in, OR the operator's
// shared key — any one flips the row done.
// `insightsConfigured` is `ai.provider.configured` from `/api/auth/me`,
// true for a personal key, a local model, an OAuth sign-in, OR the
// operator's shared key; any one flips the row done.
const on = buildChecklist(inputs({ insightsConfigured: true }));
expect(on.find((i) => i.id === "insights")?.done).toBe(true);
});
it("leaves the AI row out when AI cannot be set up here", () => {
// `provider.canConfigure` is false while the operator has AI switched
// off and inside a record somebody else owns. HealthLog is complete
// without AI, so the list never carries a to-do nobody here can finish.
const items = buildChecklist(inputs({ aiConfigurable: false }));
expect(items.map((i) => i.id)).not.toContain("insights");
expect(items).toHaveLength(5);
});
it("marks profile done when all three fields set", () => {
const items = buildChecklist(inputs());
expect(items.find((i) => i.id === "profile")?.done).toBe(true);
@@ -293,6 +303,7 @@ describe("visibleChecklist + checklistProgress", () => {
dataSourceConnected: false,
notificationsConfigured: false,
insightsConfigured: false,
aiConfigurable: true,
dismissedIds: new Set(),
upcomingVisitCount: 0,
managedProfileCount: 0,
+20 -8
View File
@@ -74,12 +74,20 @@ export interface ChecklistInputs {
/**
* True iff any AI provider can serve this user — a personal key,
* a local model, an OAuth sign-in, OR the operator's shared key.
* Derived from `/api/user/ai-provider`'s `aiAvailable`, so the row
* self-satisfies the moment insights become reachable (including on a
* `ai.provider.configured` from `GET /api/auth/me`, so the row
* self-satisfies the moment a provider is set up (including on a
* deployment that ships a shared operator key). Presence-only — it
* never decrypts a credential or probes liveness.
*/
insightsConfigured: boolean;
/**
* Whether AI can be set up from here at all (`ai.provider.canConfigure`):
* false while the operator has AI switched off and inside a record
* somebody else owns. The AI row exists only when true. HealthLog is
* complete without AI, so the list never carries a to-do nobody on this
* screen can finish.
*/
aiConfigurable: boolean;
/** Dismissed item ids (per-item localStorage state). */
dismissedIds: ReadonlySet<ChecklistItemId>;
/**
@@ -197,12 +205,16 @@ export function buildChecklist(inputs: ChecklistInputs): ChecklistItem[] {
href: "/settings/notifications",
dismissed: inputs.dismissedIds.has("notifications"),
},
{
id: "insights",
done: inputs.insightsConfigured,
href: "/settings/ai",
dismissed: inputs.dismissedIds.has("insights"),
},
...(inputs.aiConfigurable
? [
{
id: "insights" as const,
done: inputs.insightsConfigured,
href: "/settings/ai",
dismissed: inputs.dismissedIds.has("insights"),
},
]
: []),
];
if (!inputs.onboarding) return items;
// The visit row exists only for the answer that asked for it; every other
+8 -2
View File
@@ -62,8 +62,6 @@ export const settingsKeys = {
/** v1.11.0 — owner's clinician share links (Settings → Sharing). */
shareLinks: () => ["share-links"] as const,
featureFlags: () => ["feature-flags"] as const,
/**
* The account's standing AI consent receipt (`GET /api/consent/ai/latest`).
* Keyed by kind because the endpoint answers per kind and a shared key
@@ -71,4 +69,12 @@ export const settingsKeys = {
*/
aiConsentReceipt: (kind: string) =>
["consent", "ai", "latest", kind] as const,
/**
* Mutation key for granting the document-reading consent (`ai_extraction`)
* from the place that asks for it: a document read, a lab scan, the
* medication text reader.
*/
aiExtractionConsentGrant: () =>
["consent", "ai", "grant", "ai_extraction"] as const,
};