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