diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts index 63a0d5fb0..7f80a9c0f 100644 --- a/e2e/a11y.spec.ts +++ b/e2e/a11y.spec.ts @@ -3,6 +3,7 @@ import type { Locator, Page, Route } from "@playwright/test"; import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; import { openMenu } from "./open-menu"; import { POPULATED_SUMMARIES } from "./utils/mock-dashboard-snapshot"; @@ -334,6 +335,9 @@ async function visitAndScan( } async function installA11yMocks(page: Page) { + // Every AI surface renders from the `ai` block of `/api/auth/me`; the + // scans cover the AI states too, so every capability reads available. + await serveAiBlock(page, aiBlockAvailable()); await page.route(/\/api\/analytics(\?|$)/, (route) => fulfilJson(route, { summaries: POPULATED_SUMMARIES, @@ -760,12 +764,13 @@ const INSIGHTS_ROUTES: readonly RouteCase[] = [ { name: "/insights overview", path: "/insights", + // The vitals grid, settled. The scores strip is not a stable gate: it + // unmounts for an account with no score yet, and since v1.39 it no longer + // keeps an empty cell for a cycle ring that has no dial to draw. painted: (page) => - page.locator('[data-slot="wellness-scores"]').filter({ - has: page.locator( - '[data-slot="wellness-scores-grid"]:not([aria-busy="true"])', - ), - }), + page.locator( + '[data-slot="vitals-dashboard-grid"]:not([aria-busy="true"])', + ), }, { // `.recharts-wrapper` is a class the charting library writes, not a diff --git a/e2e/account-sharing.spec.ts b/e2e/account-sharing.spec.ts index cf5d0d2ab..a15a9e0ee 100644 --- a/e2e/account-sharing.spec.ts +++ b/e2e/account-sharing.spec.ts @@ -64,6 +64,7 @@ */ import type { BrowserContext, Page } from "@playwright/test"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; import { expect, test } from "./setup/test"; import { DELEGATE_STORAGE_STATE_PATH, @@ -303,10 +304,15 @@ test.describe("account sharing", () => { // delegate, in their OWN record, is offered the Coach launcher like anyone // else. Without this line the later "it is gone" check would also pass on // a build where the launcher had stopped rendering for everybody. + // The launcher follows the `coach` capability, which needs a provider the + // suite cannot reach, so the capability reads available for this one + // look; the shared-record check below reads the real payload again. + await serveAiBlock(page, aiBlockAvailable()); await page.goto("/"); await expect(page.locator('[data-slot="coach-fab"]')).toBeVisible({ timeout: 10_000, }); + await page.unroute("**/api/auth/me"); // Back to where the rest of this journey happens. The check above leaves // the browser on the dashboard, and Accept lives on the access page. @@ -369,8 +375,12 @@ test.describe("account sharing", () => { // needs to and a fast one moves on immediately. await page.goto("/"); // Gate on rendered content first (the dashboard must actually mount and - // populate the query cache before there is anything to persist). - await expect(page.locator('[data-slot="coach-fab"]')).toBeVisible({ + // populate the query cache before there is anything to persist). The + // tile strip, not the Coach launcher: the launcher follows the `coach` + // capability, which needs a provider this suite does not have. + await expect( + page.locator('[data-slot="dashboard-tile-strip"]'), + ).toBeVisible({ timeout: 10_000, }); await expect diff --git a/e2e/ai-optional-account.spec.ts b/e2e/ai-optional-account.spec.ts new file mode 100644 index 000000000..3e7f138fe --- /dev/null +++ b/e2e/ai-optional-account.spec.ts @@ -0,0 +1,119 @@ +/** + * What happens to stored AI text and stored Coach rows, against the real + * server, on an account of its own (`E2E_AI_OPTIONAL`). + * + * J5 — consent withdrawn. The account has a local model (an address nothing + * listens on: presence is all the capability reads, nothing here calls + * it), a daily briefing a model wrote earlier, and an AI consent. The + * briefing is on the overview; the consent is withdrawn in Settings, AI + * through the real control; the briefing is gone, because the server + * deleted the notes written under that consent. + * J4 — Coach hidden. With Hide Coach on, Settings, Coach is out of reach, so + * Settings, AI lists what the Coach stored; one stored conversation is + * deleted from there and is gone from the database. + * + * Both journeys move the same account's state, so they run serially, in one + * project (see `playwright.config.ts`). + */ +import type { Page } from "@playwright/test"; + +import { AI_OPTIONAL_STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + coachConversationCount, + resetAiOptionalAccount, + seedHiddenCoachWithConversation, + seedStoredBriefing, +} from "./setup/ai-optional-fixture"; +import { expect, test } from "./setup/test"; + +test.use({ storageState: AI_OPTIONAL_STORAGE_STATE_PATH }); +test.describe.configure({ mode: "serial" }); + +test.beforeEach(async () => { + await resetAiOptionalAccount(); +}); +test.afterAll(async () => { + await resetAiOptionalAccount(); +}); + +async function openOverview(page: Page): Promise { + await page.goto("/insights"); + await expect(page.locator('[data-slot="insights-hero-strip"]')).toBeVisible({ + timeout: 20_000, + }); + await page.waitForLoadState("networkidle"); +} + +test("J5: withdrawing AI consent deletes the notes written under it", async ({ + page, +}) => { + test.setTimeout(90_000); + const marker = `Stored briefing ${Date.now()}`; + await seedStoredBriefing(marker); + + // Positive control: the stored briefing is what the overview shows. + await openOverview(page); + await expect(page.locator('[data-slot="daily-briefing"]')).toHaveCount(1); + await expect(page.getByText(marker).first()).toBeVisible(); + + // Withdraw through the real control. The confirm names what goes and what + // stays before anything happens. + await page.goto("/settings/ai"); + await page.locator('[data-slot="ai-consent-withdraw"]').click(); + const withdrawn = page.waitForResponse( + (res) => + res.url().includes("/api/consent/ai/latest") && + res.request().method() === "DELETE", + ); + await page.locator('[data-slot="ai-consent-withdraw-confirm"]').click(); + expect((await withdrawn).status()).toBe(200); + await expect(page.locator('[data-slot="ai-consent-grant"]')).toBeVisible(); + + await openOverview(page); + await expect(page.getByText(marker)).toHaveCount(0); +}); + +test("J4: the Coach memory stays readable and deletable with the Coach hidden", async ({ + page, +}) => { + test.setTimeout(90_000); + const title = `Stored conversation ${Date.now()}`; + await seedHiddenCoachWithConversation(title); + + // Hide Coach takes the Coach page and its launcher away... + await page.goto("/insights"); + await expect(page.locator('[data-slot="insights-hero-strip"]')).toBeVisible({ + timeout: 20_000, + }); + await expect(page.locator('[data-slot="coach-fab"]')).toHaveCount(0); + + // ...and Settings, AI lists what the Coach stored. + await page.goto("/settings/ai"); + const card = page.locator( + '[data-testid="settings-coach-conversations-card"]', + ); + await expect(card).toBeVisible({ timeout: 20_000 }); + const row = card + .locator('[data-testid="settings-coach-conversation"]') + .filter({ hasText: title }); + await expect(row).toHaveCount(1); + + // Readable: the row opens its transcript in place. + await row.locator('[data-slot="settings-coach-conversation-open"]').click(); + await expect( + row.locator('[data-slot="settings-coach-conversation-open"]'), + ).toHaveAttribute("aria-expanded", "true"); + + // Deletable: the row goes at once, and the delete reaches the server once + // the undo window closes. + const deleted = page.waitForResponse( + (res) => + /\/api\/insights\/chat\/[^/]+$/.test(new URL(res.url()).pathname) && + res.request().method() === "DELETE", + { timeout: 30_000 }, + ); + await row.locator('[data-slot="settings-coach-conversation-delete"]').click(); + await expect(row).toHaveCount(0); + expect((await deleted).ok()).toBe(true); + expect(await coachConversationCount()).toBe(0); +}); diff --git a/e2e/ai-optional-overview.spec.ts b/e2e/ai-optional-overview.spec.ts new file mode 100644 index 000000000..69c8bac60 --- /dev/null +++ b/e2e/ai-optional-overview.spec.ts @@ -0,0 +1,164 @@ +/** + * Insights without AI (#975): switched off by the operator, or never set up. + * + * J1 — every AI capability `operator_disabled`, nothing configurable here. + * The overview renders its data sections; nothing AI-shaped paints, not + * even a hint; not one request goes to a route that calls a model or + * serves model text, and no request is answered 403. + * J3 — no provider, one could be set up here. Exactly one calm setup hint on + * the overview, no Coach launcher, no per-card "connect a provider" + * state. Dismissing the hint in Settings → AI keeps it gone on the + * overview. + * + * Both states are served through the `ai` block of `/api/auth/me` + * (`serveAiBlock`, which says why): the operator's switches are instance-wide + * and a parallel spec would lose its AI under a real flip. + * + * Every assertion is addressed to a stable `data-*` attribute, never copy. + */ +import type { Page, Request, Response } from "@playwright/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockUnavailable, serveAiBlock } from "./setup/ai-capabilities"; +import { expect, test } from "./setup/test"; + +test.use({ storageState: STORAGE_STATE_PATH }); + +/** Routes that call a model or serve model-written text. */ +const AI_ROUTE = new RegExp( + [ + "/api/insights/generate", + "/api/insights/(weight|bmi|pulse|blood-pressure|mood|medication-compliance)-status", + "/api/insights/metric-status", + "/api/insights/biomarker-assessment", + "/api/insights/chat", + "/api/insights/coach/nudge-status", + "/api/medications/extract", + "/api/labs/ocr/", + "/api/feature-flags", + ].join("|"), +); + +function watchNetwork(page: Page): { + aiRequests: string[]; + refused: string[]; +} { + const aiRequests: string[] = []; + const refused: string[] = []; + page.on("request", (request: Request) => { + const url = new URL(request.url()); + if (AI_ROUTE.test(url.pathname)) aiRequests.push(url.pathname); + }); + page.on("response", (response: Response) => { + const url = new URL(response.url()); + if (url.pathname.startsWith("/api/") && response.status() === 403) { + refused.push(url.pathname); + } + }); + return { aiRequests, refused }; +} + +async function clearHintDismissal(page: Page): Promise { + await page.evaluate(() => { + for (const key of Object.keys(window.localStorage)) { + if (key.startsWith("healthlog.ai-setup-hint.")) { + window.localStorage.removeItem(key); + } + } + }); +} + +test.describe("J1: AI switched off by the operator", () => { + test("the overview shows data, no AI, no AI requests and no refusals", async ({ + page, + }) => { + test.setTimeout(60_000); + await serveAiBlock( + page, + aiBlockUnavailable("operator_disabled", { + configured: false, + managedBy: null, + canConfigure: false, + }), + ); + const network = watchNetwork(page); + + await page.goto("/insights"); + await expect(page.locator('[data-slot="insights-hero-strip"]')).toBeVisible( + { timeout: 20_000 }, + ); + // Let every section's reads land before judging what painted. + await page.waitForLoadState("networkidle"); + + await expect( + page.locator('[data-slot="insights-overview-error"]'), + ).toHaveCount(0); + await expect( + page.locator('[data-slot="insights-overview-section-error"]'), + ).toHaveCount(0); + await expect(page.locator('[data-slot="chart-error-state"]')).toHaveCount( + 0, + ); + await expect(page.locator('[data-slot="daily-briefing"]')).toHaveCount(0); + await expect(page.locator('[data-slot="ai-setup-hint"]')).toHaveCount(0); + await expect(page.locator('[data-slot="coach-fab"]')).toHaveCount(0); + + // The ECG page is data and never waits on AI. + await page.goto("/insights/ecg"); + await page.waitForLoadState("networkidle"); + await expect(page.locator('[data-slot="query-error-card"]')).toHaveCount(0); + + expect(network.aiRequests).toEqual([]); + expect(network.refused).toEqual([]); + }); +}); + +test.describe("J3: no AI provider", () => { + test("one calm setup hint, no Coach, no per-card provider state", async ({ + page, + }) => { + test.setTimeout(60_000); + await serveAiBlock( + page, + aiBlockUnavailable("no_provider", { + configured: false, + managedBy: null, + canConfigure: true, + }), + ); + const network = watchNetwork(page); + + await page.goto("/insights"); + await clearHintDismissal(page); + await page.reload(); + + await expect(page.locator('[data-slot="ai-setup-hint"]')).toHaveCount(1, { + timeout: 20_000, + }); + await page.waitForLoadState("networkidle"); + await expect(page.locator('[data-slot="coach-fab"]')).toHaveCount(0); + await expect(page.locator('[data-slot="daily-briefing"]')).toHaveCount(0); + await expect( + page.locator('[data-slot="insight-status-no-provider-cta"]'), + ).toHaveCount(0); + await expect( + page.locator('[data-slot="daily-briefing-no-provider"]'), + ).toHaveCount(0); + + // Settings → AI carries the same hint; dismissing it there is remembered. + await page.goto("/settings/ai"); + const hint = page.locator('[data-slot="ai-setup-hint"]'); + await expect(hint).toHaveCount(1, { timeout: 20_000 }); + await page.locator('[data-slot="ai-setup-hint-dismiss"]').click(); + await expect(hint).toHaveCount(0); + + await page.goto("/insights"); + await expect(page.locator('[data-slot="insights-hero-strip"]')).toBeVisible( + { timeout: 20_000 }, + ); + await page.waitForLoadState("networkidle"); + await expect(page.locator('[data-slot="ai-setup-hint"]')).toHaveCount(0); + + expect(network.aiRequests).toEqual([]); + }); +}); diff --git a/e2e/coach-conversation-rename.spec.ts b/e2e/coach-conversation-rename.spec.ts index 1a8677e9f..aff2c0e94 100644 --- a/e2e/coach-conversation-rename.spec.ts +++ b/e2e/coach-conversation-rename.spec.ts @@ -2,12 +2,20 @@ import { expect, test } from "./setup/test"; import { setTimeout as delay } from "node:timers/promises"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; const CONVERSATION_ID = "conversation-rename-e2e"; const INITIAL_TITLE = "Morning check-in"; const RENAMED_TITLE = "Weekly health review"; test.describe("Coach conversation rename", () => { + // AI surfaces render from the `ai` block of `/api/auth/me`, and the suite + // has no reachable provider; every capability reads available here so the + // mocked AI routes below are what the surfaces talk to. + test.beforeEach(async ({ page }) => { + await serveAiBlock(page, aiBlockAvailable()); + }); + test.use({ storageState: STORAGE_STATE_PATH }); test("renames by keyboard once and survives reload on desktop and mobile", async ({ diff --git a/e2e/coach-guided-questions.spec.ts b/e2e/coach-guided-questions.spec.ts index 638d9c74e..036b86d1c 100644 --- a/e2e/coach-guided-questions.spec.ts +++ b/e2e/coach-guided-questions.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; /** * Guided clarifying-questions flow on the full-page Coach route @@ -31,6 +32,13 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; * ordering contract is pinned here). */ test.describe("Coach guided clarifying questions", () => { + // AI surfaces render from the `ai` block of `/api/auth/me`, and the suite + // has no reachable provider; every capability reads available here so the + // mocked AI routes below are what the surfaces talk to. + test.beforeEach(async ({ page }) => { + await serveAiBlock(page, aiBlockAvailable()); + }); + test.use({ storageState: STORAGE_STATE_PATH }); const QUESTIONS = [ diff --git a/e2e/documents-ai.spec.ts b/e2e/documents-ai.spec.ts index 17a6e786c..9f2fad569 100644 --- a/e2e/documents-ai.spec.ts +++ b/e2e/documents-ai.spec.ts @@ -20,6 +20,7 @@ import type { Page } from "@playwright/test"; import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; import { ensureVaultFixture, ensureVaultAiFixture, @@ -121,6 +122,13 @@ async function mockAiEnabled( } test.describe("document vault — AI assist + content search", () => { + // AI surfaces render from the `ai` block of `/api/auth/me`, and the suite + // has no reachable provider; every capability reads available here so the + // mocked AI routes below are what the surfaces talk to. + test.beforeEach(async ({ page }) => { + await serveAiBlock(page, aiBlockAvailable()); + }); + test.use({ storageState: STORAGE_STATE_PATH }); test.beforeAll(async () => { diff --git a/e2e/documents-auto-read.spec.ts b/e2e/documents-auto-read.spec.ts index 867a72e35..be6087370 100644 --- a/e2e/documents-auto-read.spec.ts +++ b/e2e/documents-auto-read.spec.ts @@ -19,6 +19,7 @@ import type { Page } from "@playwright/test"; import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; import { ensureVaultFixture, ensureVaultAiFixture, @@ -176,6 +177,13 @@ test.describe("automatic AI reading — settings toggle", () => { }); test.describe("automatic AI reading — vault per-document contract", () => { + // AI surfaces render from the `ai` block of `/api/auth/me`, and the suite + // has no reachable provider; every capability reads available here so the + // mocked AI routes below are what the surfaces talk to. + test.beforeEach(async ({ page }) => { + await serveAiBlock(page, aiBlockAvailable()); + }); + test.use({ storageState: STORAGE_STATE_PATH }); test.beforeAll(async () => { diff --git a/e2e/documents-chat.spec.ts b/e2e/documents-chat.spec.ts index 85afe9b79..44248f031 100644 --- a/e2e/documents-chat.spec.ts +++ b/e2e/documents-chat.spec.ts @@ -26,6 +26,7 @@ import type { Page } from "@playwright/test"; import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; import { ensureVaultFixture, ensureVaultAiFixture, @@ -86,6 +87,13 @@ async function mockAiEnabled(page: Page): Promise { } test.describe("document vault — chat about a document", () => { + // AI surfaces render from the `ai` block of `/api/auth/me`, and the suite + // has no reachable provider; every capability reads available here so the + // mocked AI routes below are what the surfaces talk to. + test.beforeEach(async ({ page }) => { + await serveAiBlock(page, aiBlockAvailable()); + }); + test.use({ storageState: STORAGE_STATE_PATH }); test.beforeAll(async () => { diff --git a/e2e/documents.spec.ts b/e2e/documents.spec.ts index 6fbcfc54b..ac82ceebe 100644 --- a/e2e/documents.spec.ts +++ b/e2e/documents.spec.ts @@ -21,6 +21,7 @@ import type { Page } from "@playwright/test"; import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; import { ensureVaultFixture, seedNamespaceDocs, @@ -137,6 +138,13 @@ async function mockDocumentCoach(page: Page): Promise { } test.describe("document vault", () => { + // AI surfaces render from the `ai` block of `/api/auth/me`, and the suite + // has no reachable provider; every capability reads available here so the + // mocked AI routes below are what the surfaces talk to. + test.beforeEach(async ({ page }) => { + await serveAiBlock(page, aiBlockAvailable()); + }); + test.use({ storageState: STORAGE_STATE_PATH }); test.beforeAll(async () => { diff --git a/e2e/insights-generate.spec.ts b/e2e/insights-generate.spec.ts index 1bce560a7..5a733c773 100644 --- a/e2e/insights-generate.spec.ts +++ b/e2e/insights-generate.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; /** * Insights generation flow — `/settings/ai` exposes a "Regenerate @@ -19,6 +20,13 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; * they're touched after the regenerate call). */ test.describe("insights generation flow (mocked)", () => { + // AI surfaces render from the `ai` block of `/api/auth/me`, and the suite + // has no reachable provider; every capability reads available here so the + // mocked AI routes below are what the surfaces talk to. + test.beforeEach(async ({ page }) => { + await serveAiBlock(page, aiBlockAvailable()); + }); + test.use({ storageState: STORAGE_STATE_PATH }); test("regenerate button updates insight cards", async ({ page }) => { diff --git a/e2e/setup/ai-capabilities.ts b/e2e/setup/ai-capabilities.ts new file mode 100644 index 000000000..a59901aab --- /dev/null +++ b/e2e/setup/ai-capabilities.ts @@ -0,0 +1,91 @@ +/** + * Serve `GET /api/auth/me` with a chosen `ai` block. + * + * Every AI surface on the web renders from the capability map `/me` carries, + * and nothing else. The operator's switches are instance-wide, so flipping + * the real master switch in one spec would switch AI off under every spec + * running beside it; a real provider cannot be reached from the suite at all. + * A journey that asserts what the web does in one AI state therefore takes + * the real `/me` answer and replaces only its `ai` block, and states the + * limit plainly: it proves the web's rendering and its requests for that + * state. What the server publishes for which inputs is proven by the + * resolver's unit tests and `tests/integration/auth-me-ai-capabilities`. + */ +import type { Page } from "@playwright/test"; + +export const AI_CAPABILITY_KEYS = [ + "coach", + "briefing", + "periodNarrative", + "statusText", + "workoutInsights", + "reactionLines", + "aboutMeQuestions", + "documentAi", + "labsOcr", + "medicationExtract", +] as const; + +type Reason = + | "operator_disabled" + | "not_permitted_for_record" + | "module_disabled" + | "user_disabled" + | "no_provider" + | "consent_required" + | "check_failed"; + +interface AiBlock { + capabilities: Record< + string, + { available: boolean; reason: Reason | null; onDeviceAllowed: boolean } + >; + provider: { + configured: boolean; + managedBy: "user" | "local" | "server" | null; + canConfigure: boolean; + }; +} + +/** Every capability unavailable for one reason. */ +export function aiBlockUnavailable( + reason: Reason, + provider: AiBlock["provider"], +): AiBlock { + const onDeviceAllowed = + reason === "no_provider" || reason === "consent_required"; + return { + capabilities: Object.fromEntries( + AI_CAPABILITY_KEYS.map((key) => [ + key, + { available: false, reason, onDeviceAllowed }, + ]), + ), + provider, + }; +} + +/** Every capability available, a provider of the person's own. */ +export function aiBlockAvailable(): AiBlock { + return { + capabilities: Object.fromEntries( + AI_CAPABILITY_KEYS.map((key) => [ + key, + { available: true, reason: null, onDeviceAllowed: true }, + ]), + ), + provider: { configured: true, managedBy: "user", canConfigure: true }, + }; +} + +/** Replace the `ai` block of every `/api/auth/me` answer on this page. */ +export async function serveAiBlock(page: Page, ai: AiBlock): Promise { + await page.route("**/api/auth/me", async (route) => { + const response = await route.fetch(); + const body = (await response.json()) as { + data: Record | null; + }; + if (body.data) body.data.ai = ai; + await route.fulfill({ response, json: body }); + }); +} diff --git a/e2e/setup/ai-optional-fixture.ts b/e2e/setup/ai-optional-fixture.ts new file mode 100644 index 000000000..bea502931 --- /dev/null +++ b/e2e/setup/ai-optional-fixture.ts @@ -0,0 +1,129 @@ +/** + * Fixture for the AI-optional journeys that need server state + * (`e2e/ai-optional-consent.spec.ts`, `e2e/ai-optional-coach-memory.spec.ts`). + * + * Both run on their own account (`E2E_AI_OPTIONAL`). What they need has no + * product endpoint that can write it without a working model: + * + * - a provider the account can "have" without any egress: a local model on + * an address nothing listens on. Presence is what the capability reads; + * nothing on these journeys calls it. + * - a briefing a model wrote earlier, stored as the cache the read path + * serves. The journey proves it is deleted when the consent goes. + * - an AI consent receipt to withdraw. + * - a Coach conversation, which only a chat with a model would create. + * + * Every read and every change the journeys ASSERT goes through the app; this + * file only prepares the starting state, through `pg` like the other fixtures. + */ +import pg from "pg"; + +import { E2E_AI_OPTIONAL } from "./global-setup"; + +function connect(): pg.Pool { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("[ai-optional-fixture] DATABASE_URL is not set"); + return new pg.Pool({ connectionString: url }); +} + +async function withAccount( + run: (pool: pg.Pool, userId: string) => Promise, +): Promise { + const pool = connect(); + try { + const { rows } = await pool.query<{ id: string }>( + "SELECT id FROM users WHERE username = $1", + [E2E_AI_OPTIONAL.username], + ); + const userId = rows[0]?.id; + if (!userId) { + throw new Error( + "[ai-optional-fixture] account not seeded — global-setup must run first", + ); + } + return await run(pool, userId); + } finally { + await pool.end(); + } +} + +/** Back to a plain account: no provider, no stored AI text, no consent. */ +export async function resetAiOptionalAccount(): Promise { + await withAccount(async (pool, userId) => { + await pool.query( + `UPDATE users SET + ai_provider = NULL, + ai_base_url = NULL, + insights_cached_text = NULL, + insights_cached_at = NULL, + insights_cached_locale = NULL, + disable_coach = false + WHERE id = $1`, + [userId], + ); + await pool.query("DELETE FROM consent_receipts WHERE user_id = $1", [ + userId, + ]); + await pool.query("DELETE FROM coach_conversations WHERE user_id = $1", [ + userId, + ]); + }); +} + +/** + * A local provider, a stored daily briefing whose paragraph is `marker`, and + * an active AI consent. + */ +export async function seedStoredBriefing(marker: string): Promise { + const insights = { + dailyBriefing: { paragraph: marker, keyFindings: [] }, + }; + await withAccount(async (pool, userId) => { + await pool.query( + `UPDATE users SET + ai_provider = 'LOCAL', + ai_base_url = 'http://127.0.0.1:9/v1', + insights_cached_text = $2, + insights_cached_at = now(), + insights_cached_locale = 'en' + WHERE id = $1`, + [userId, JSON.stringify(insights)], + ); + await pool.query( + `INSERT INTO consent_receipts (id, user_id, kind, artefact, signed_at) + VALUES ($1, $2, 'ai_full', $3, now())`, + [ + `e2e-consent-${Date.now()}`, + userId, + JSON.stringify({ source: "e2e", kind: "ai_full" }), + ], + ); + }); +} + +/** Hide the Coach and give it one stored conversation titled `title`. */ +export async function seedHiddenCoachWithConversation( + title: string, +): Promise { + await withAccount(async (pool, userId) => { + await pool.query("UPDATE users SET disable_coach = true WHERE id = $1", [ + userId, + ]); + await pool.query( + `INSERT INTO coach_conversations (id, user_id, title, created_at, updated_at) + VALUES ($1, $2, $3, now(), now())`, + [`e2e-conv-${Date.now()}`, userId, title], + ); + }); +} + +/** How many Coach conversations the account still stores. */ +export async function coachConversationCount(): Promise { + return withAccount(async (pool, userId) => { + const { rows } = await pool.query<{ n: string }>( + "SELECT count(*)::text AS n FROM coach_conversations WHERE user_id = $1", + [userId], + ); + return Number(rows[0]?.n ?? 0); + }); +} diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 50791808c..bfd62f548 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -165,6 +165,26 @@ export const MODULES_STORAGE_STATE_PATH = resolve( "e2e/setup/storageStateModules.json", ); +/** + * The AI-optional journeys' own account (`ai-optional-*.spec.ts`). + * + * The consent journey gives it a provider, a stored briefing and an AI + * consent, then withdraws the consent; the Coach-memory journey hides its + * Coach and deletes a stored conversation. Both change what every AI surface + * of the account shows, so they run on a record nobody else reads. + */ +export const E2E_AI_OPTIONAL = { + email: "e2e-ai-optional@healthlog.test", + username: "e2e-ai-optional", + password: "Rw5!Nc9pT3xLq6Zb", + role: "USER", +} as const; + +export const AI_OPTIONAL_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateAiOptional.json", +); + /** * The notification-dispatch journey's own account. * @@ -766,6 +786,29 @@ export default async function globalSetup(config: FullConfig): Promise { ], ); + // The AI-optional journeys' account. Its provider, stored briefing, + // consent and Coach rows are set by `e2e/setup/ai-optional-fixture.ts` + // before each journey. + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed) + VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + updated_at = EXCLUDED.updated_at, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed`, + [ + cuid(), + E2E_AI_OPTIONAL.username, + E2E_AI_OPTIONAL.email, + await hashPassword(E2E_AI_OPTIONAL.password), + now, + ], + ); + // The notification-dispatch journey's account. Seeded like the others; // its channels, devices, ledger rows and preferences are reset by // `e2e/setup/notification-fixture.ts` before every test, because the @@ -1265,7 +1308,7 @@ export default async function globalSetup(config: FullConfig): Promise { * Log one account in, clearing the login bucket first. * * The ceiling is FIVE attempts per IP per quarter-hour and this setup now - * signs in seventeen times, so clearing once before the batch is no longer + * signs in eighteen times, so clearing once before the batch is no longer * enough — the sixth would be answered by the fixture's own 429 rather * than by the product. Only the auth surfaces' buckets are touched, and * only between logins this setup is itself performing. @@ -1302,6 +1345,9 @@ export default async function globalSetup(config: FullConfig): Promise { // The module-surfaces journey's jar — see `E2E_MODULES`. await capture(E2E_MODULES, MODULES_STORAGE_STATE_PATH); + // The AI-optional journeys' jar — see `E2E_AI_OPTIONAL`. + await capture(E2E_AI_OPTIONAL, AI_OPTIONAL_STORAGE_STATE_PATH); + await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SCOPE_CAPABILITIES_STORAGE_STATE_PATH); diff --git a/e2e/v1427-coach-launch.spec.ts b/e2e/v1427-coach-launch.spec.ts index 724a99535..fc36a45e6 100644 --- a/e2e/v1427-coach-launch.spec.ts +++ b/e2e/v1427-coach-launch.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; /** * Coach launch surfaces on `/insights/{slug}` sub-pages. @@ -31,6 +32,13 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; * data → full chart). The FAB launcher is present on both. */ test.describe("Coach launch surfaces on insights sub-pages", () => { + // AI surfaces render from the `ai` block of `/api/auth/me`, and the suite + // has no reachable provider; every capability reads available here so the + // mocked AI routes below are what the surfaces talk to. + test.beforeEach(async ({ page }) => { + await serveAiBlock(page, aiBlockAvailable()); + }); + test.use({ storageState: STORAGE_STATE_PATH }); test.beforeEach(async ({ page }) => { diff --git a/e2e/v1427-insights-empty-state.spec.ts b/e2e/v1427-insights-empty-state.spec.ts index 62bbcff16..1f6027ff3 100644 --- a/e2e/v1427-insights-empty-state.spec.ts +++ b/e2e/v1427-insights-empty-state.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; /** * v1.4.27 F17 — insights metric-availability gating. @@ -19,6 +20,13 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; * branches. */ test.describe("v1.4.27 — insights empty-state with metric gating", () => { + // AI surfaces render from the `ai` block of `/api/auth/me`, and the suite + // has no reachable provider; every capability reads available here so the + // mocked AI routes below are what the surfaces talk to. + test.beforeEach(async ({ page }) => { + await serveAiBlock(page, aiBlockAvailable()); + }); + test.use({ storageState: STORAGE_STATE_PATH }); test.beforeEach(async ({ page }) => { diff --git a/messages/de.json b/messages/de.json index 419b0f16b..ae1e581a3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1312,7 +1312,9 @@ "rateLimit": "Zu viele Anfragen — bitte kurz warten und erneut versuchen.", "noProvider": "Kein KI-Anbieter konfiguriert — bitte in den Einstellungen einrichten.", "budget": "Tägliches KI-Budget erreicht — bitte morgen erneut versuchen.", - "network": "KI-Anbieter nicht erreichbar — bitte erneut versuchen." + "network": "KI-Anbieter nicht erreichbar — bitte erneut versuchen.", + "consent": "Für das Lesen deiner Beschreibung mit KI brauchst du zuerst deine Einwilligung.", + "unavailable": "Das Lesen von Beschreibungen mit KI ist hier gerade nicht verfügbar." } }, "cadence": { @@ -2444,8 +2446,6 @@ "bmiEmptyAction": "Account-Einstellungen öffnen", "noAnalysisYet": "Noch keine Einschätzung vorhanden.", "assessmentPreparing": "Einschätzung wird vorbereitet…", - "noProviderConfigured": "Verbinde einen KI-Anbieter, um hier eine Einschätzung zu deinen Werten zu sehen.", - "noProviderAction": "Zu den KI-Einstellungen", "personalRecord": { "badge": "PR", "tooltip": "Persönlicher Rekord: {value} {unit} am {date}" @@ -2569,10 +2569,6 @@ "emptyDescription": "Erstelle das heutige Briefing, um eine Zusammenfassung deiner Trends zu bekommen.", "emptyAction": "Briefing erstellen", "loadingLabel": "Briefing wird zusammengestellt", - "noProviderTitle": "Kein KI-Anbieter verbunden", - "noProviderDescription": "Richte HealthLog auf ein lokales Modell (Ollama, LM Studio — nichts verlässt dein Netzwerk) oder hinterlege deinen eigenen OpenAI- oder Anthropic-Schlüssel, und das tägliche Briefing fasst deine eigenen Messwerte in klarer Sprache zusammen. Die Einrichtung dauert etwa eine Minute.", - "noProviderAction": "Zu den KI-Einstellungen", - "staleNoProviderHint": "Dieses Briefing kann nicht aktualisiert werden — es ist kein KI-Anbieter verbunden.", "refreshFailedHint": "Briefing konnte nicht aktualisiert werden — es wird das letzte angezeigt.", "retryAction": "Erneut versuchen", "failedTitle": "Briefing konnte nicht erstellt werden", @@ -2950,7 +2946,15 @@ "interrupted": { "note": "Diese Antwort wurde unterbrochen, bevor sie fertig war.", "retry": "Erneut fragen" - } + }, + "historyReadOnlyNote": "Der Coach ist gerade nicht verfügbar, deine Gespräche kannst du aber weiter lesen und löschen.", + "transcriptYou": "Du", + "transcriptCoach": "Coach", + "errorConsent": "Der Coach braucht deine KI-Einwilligung, bevor er antworten kann. Du kannst sie unter Einstellungen, KI geben.", + "errorNotPermitted": "Der Coach ist in einer Akte, die jemand mit dir teilt, nicht verfügbar.", + "errorUnavailable": "Der Coach konnte seine Einstellungen gerade nicht prüfen. Bitte versuch es gleich noch einmal.", + "errorOperatorOff": "KI ist auf diesem Server abgeschaltet, deshalb kann der Coach nicht antworten.", + "errorCoachOff": "Der Coach ist ausgeschaltet. Du kannst ihn unter Einstellungen, Module wieder einschalten." }, "relativeJustNow": "gerade eben", "relativeMinutesAgoOne": "vor {count} Minute", @@ -4383,7 +4387,15 @@ "night": "Eine anhaltend erhöhte Ruhephase in der Nacht — möglicherweise etwas Anspannung." } }, - "navEcg": "EKG" + "navEcg": "EKG", + "sectionLoadError": "Dieser Bereich konnte nicht geladen werden.", + "aiSetupHint": { + "title": "KI ist optional", + "body": "Hier funktioniert alles auch ohne KI. Wenn du ein tägliches Briefing, kurze Notizen zu deinen Werten und den Coach möchtest, kannst du einen KI-Anbieter verbinden.", + "settingsBody": "HealthLog funktioniert vollständig ohne KI. Verbinde unten einen Anbieter, wenn du ein tägliches Briefing, kurze Notizen zu deinen Werten und den Coach möchtest.", + "action": "KI einrichten", + "dismiss": "Nicht jetzt" + } }, "thresholds": { "defaultLabel": "Default", @@ -5831,12 +5843,30 @@ "activeSince": "Erteilt am {date}. Deine Gesundheitsdaten dürfen für die KI-Funktionen an den eingestellten Anbieter gesendet werden.", "withdrawn": "Widerrufen. Es werden keine Gesundheitsdaten an einen KI-Anbieter gesendet; die KI-Funktionen bleiben aus.", "withdraw": "Einwilligung widerrufen", - "confirmBody": "Die KI-Funktionen sind sofort abgeschaltet. Bereits erfasste Daten werden nicht gelöscht — dafür gibt es Daten & Privatsphäre.", + "confirmBody": "Die KI-Funktionen stoppen sofort, und die Notizen, die ein Modell für dich geschrieben hat, werden gelöscht: Statusnotizen, das tägliche Briefing, von einem Modell geschriebene Zeitraum-Zusammenfassungen, Ankunftszeilen und Trainingsnotizen. Deine Coach-Gespräche, was sich der Coach gemerkt hat, deine Pläne und deine Dokument-Zusammenfassungen bleiben, und du kannst sie selbst lesen oder löschen.", "confirmWithdraw": "Widerrufen", "regrantHint": "Mit einer neuen Einwilligung dürfen die KI-Funktionen deine Gesundheitsdaten wieder an den eingestellten Anbieter senden.", "grant": "Einwilligung erteilen", "grantError": "Die Einwilligung konnte nicht gespeichert werden. Bitte versuche es erneut.", - "error": "Der Widerruf konnte nicht gespeichert werden. Bitte versuche es erneut." + "error": "Der Widerruf konnte nicht gespeichert werden. Bitte versuche es erneut.", + "documentReading": { + "title": "Einwilligung zum Lesen von Dokumenten", + "activeSince": "Erteilt am {date}. Dokumente, gescannte Laborbefunde und Medikamentenbeschreibungen dürfen zum Lesen an deinen KI-Dienst gesendet werden.", + "confirmBody": "Dokumente, gescannte Laborbefunde und Medikamentenbeschreibungen werden nicht mehr zum Lesen gesendet. Nichts, was du bereits hast, wird gelöscht." + } + }, + "operatorOff": { + "allTitle": "KI ist auf diesem Server abgeschaltet", + "allBody": "Wer diesen Server betreibt, hat KI abgeschaltet. HealthLog funktioniert weiter mit all deinen Daten; die Funktionen, die KI brauchen, werden einfach nicht angezeigt.", + "someTitle": "Einige KI-Funktionen sind auf diesem Server abgeschaltet", + "someBody": "Wer diesen Server betreibt, hat abgeschaltet: {switches}. Alles andere, auch all deine Daten, funktioniert wie gewohnt.", + "testRefused": "KI ist auf diesem Server abgeschaltet, daher gibt es nichts zu testen.", + "autoRead": "Das Lesen von Dokumenten mit KI ist auf diesem Server abgeschaltet." + }, + "coachConversations": { + "title": "Coach-Gespräche", + "description": "Öffne ein Gespräch, um es zu lesen, oder lösche es.", + "more": "Mehr anzeigen" } }, "dashboard": { @@ -8801,7 +8831,8 @@ "extractFailed": "Der Befund konnte nicht gelesen werden. Versuche ein schärferes Foto.", "pdfNeedsAnthropic": "PDF-Scannen benötigt einen Claude-Bildanbieter; lade stattdessen ein Foto hoch.", "refTextLabel": "Bereich wie gedruckt", - "refTextPlaceholder": "z. B. 3,5 - 5,0" + "refTextPlaceholder": "z. B. 3,5 - 5,0", + "aiUnavailable": "Das Scannen eines Befunds mit KI ist hier gerade nicht verfügbar." }, "loadMoreReadings": "Weitere Werte laden", "documentsLink": "Befund-Dokumente", @@ -9949,7 +9980,9 @@ "errorNotIndexed": "Lies das Dokument zuerst aus, dann extrahiere.", "errorLocalOcrDisabled": "Aktiviere zuerst die lokale Texterkennung in den KI-Einstellungen.", "errorProvider": "Dafür ist kein KI-Anbieter eingerichtet.", - "errorTextImageOnly": "Lokale Texterkennung liest nur Bilder — nicht diese Datei." + "errorTextImageOnly": "Lokale Texterkennung liest nur Bilder — nicht diese Datei.", + "errorConsent": "Für das Lesen von Dokumenten mit KI brauchst du zuerst deine Einwilligung.", + "errorUnavailable": "Das Lesen von Dokumenten mit KI ist hier gerade nicht verfügbar." }, "summary": { "summarise": "Zusammenfassen", @@ -10027,6 +10060,11 @@ "title": "Über dieses Dokument chatten", "openAria": "Coach über dieses Dokument fragen", "openButton": "Coach fragen" + }, + "readingConsent": { + "body": "Beim Lesen mit KI geht dies an deinen KI-Dienst außerhalb dieses Servers. Darf HealthLog Dokumente, Laborbefunde und Medikamentenbeschreibungen zum Lesen senden?", + "grant": "Lesen erlauben", + "error": "Die Einwilligung konnte nicht gespeichert werden. Bitte versuch es erneut." } }, "selfDescription": { diff --git a/messages/en.json b/messages/en.json index 31089bf21..aef4fcb86 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1312,7 +1312,9 @@ "rateLimit": "Too many requests — wait a moment and try again.", "noProvider": "No AI provider configured — set one up in Settings.", "budget": "Daily AI budget reached — try again tomorrow.", - "network": "Could not reach the AI provider — please retry." + "network": "Could not reach the AI provider — please retry.", + "consent": "Reading your description with AI needs your consent first.", + "unavailable": "Reading descriptions with AI is not available here right now." } }, "cadence": { @@ -2444,8 +2446,6 @@ "bmiEmptyTitle": "Set your height to see BMI", "bmiEmptyDescription": "BMI needs your height. Add it under Settings → Account.", "bmiEmptyAction": "Open account settings", - "noProviderConfigured": "Connect an AI provider to see an assessment of your readings here.", - "noProviderAction": "Open AI settings", "personalRecord": { "badge": "PR", "tooltip": "Personal record: {value} {unit} on {date}" @@ -2569,10 +2569,6 @@ "emptyDescription": "Generate today's briefing to get a narrative read of your trends.", "emptyAction": "Generate briefing", "loadingLabel": "Synthesising today's briefing", - "noProviderTitle": "No AI provider connected", - "noProviderDescription": "Point HealthLog at a local model (Ollama, LM Studio — nothing leaves your network) or bring your own OpenAI or Anthropic key, and the daily briefing turns your own readings into a plain-language summary. Setup takes about a minute.", - "noProviderAction": "Open AI settings", - "staleNoProviderHint": "This briefing can't refresh — no AI provider is connected.", "refreshFailedHint": "Couldn't refresh the briefing — showing the last one.", "retryAction": "Retry", "failedTitle": "Couldn't generate the briefing", @@ -2950,7 +2946,15 @@ "interrupted": { "note": "This answer was interrupted before it finished.", "retry": "Ask again" - } + }, + "historyReadOnlyNote": "The Coach is not available right now, but you can still read and delete your conversations.", + "transcriptYou": "You", + "transcriptCoach": "Coach", + "errorConsent": "The Coach needs your AI consent before it can answer. You can give it in Settings, AI.", + "errorNotPermitted": "The Coach is not available inside a record someone shared with you.", + "errorUnavailable": "The Coach could not check its settings just now. Please try again in a moment.", + "errorOperatorOff": "AI is switched off on this server, so the Coach cannot answer.", + "errorCoachOff": "The Coach is switched off. You can turn it back on in Settings, Modules." }, "relativeJustNow": "just now", "relativeMinutesAgoOne": "{count} minute ago", @@ -4383,7 +4387,15 @@ "night": "A sustained elevated-at-rest stretch overnight — possibly some tension." } }, - "navEcg": "ECG" + "navEcg": "ECG", + "sectionLoadError": "This section could not be loaded.", + "aiSetupHint": { + "title": "AI is optional", + "body": "Everything here works without AI. If you want a daily briefing, short notes on your readings and the Coach, you can connect an AI provider.", + "settingsBody": "HealthLog works fully without AI. Connect a provider below if you want a daily briefing, short notes on your readings and the Coach.", + "action": "Set up AI", + "dismiss": "Not now" + } }, "thresholds": { "defaultLabel": "Default", @@ -5831,12 +5843,30 @@ "activeSince": "Given on {date}. Your health data may be sent to the configured provider for the AI features.", "withdrawn": "Withdrawn. No health data is sent to an AI provider; the AI features stay switched off.", "withdraw": "Withdraw consent", - "confirmBody": "The AI features stop working straight away. Nothing already recorded is deleted — use Data & privacy to export or erase your record.", + "confirmBody": "The AI features stop straight away, and the notes a model wrote for you are deleted: status notes, the daily briefing, model-written period summaries, arrival lines and workout notes. Your Coach conversations, what the Coach remembered, your plans and your document summaries stay, and you can read or delete them yourself.", "confirmWithdraw": "Withdraw", "regrantHint": "Granting again lets the AI features send your health data to the configured provider.", "grant": "Grant consent", "grantError": "The consent could not be saved. Please try again.", - "error": "The withdrawal could not be saved. Please try again." + "error": "The withdrawal could not be saved. Please try again.", + "documentReading": { + "title": "Consent to reading documents", + "activeSince": "Given on {date}. Documents, lab report scans and medication descriptions may be sent to your AI service for reading.", + "confirmBody": "Documents, lab report scans and medication descriptions are no longer sent for reading. Nothing you already have is deleted." + } + }, + "operatorOff": { + "allTitle": "AI is switched off on this server", + "allBody": "The person who runs this server has turned AI off. HealthLog keeps working with all your data; the features that need AI are simply not shown.", + "someTitle": "Some AI features are switched off on this server", + "someBody": "The person who runs this server has turned off: {switches}. Everything else, including all your data, works as usual.", + "testRefused": "AI is switched off on this server, so there is nothing to test.", + "autoRead": "Reading documents with AI is switched off on this server." + }, + "coachConversations": { + "title": "Coach conversations", + "description": "Open a conversation to read it, or delete it.", + "more": "Show more" } }, "dashboard": { @@ -8801,7 +8831,8 @@ "extractFailed": "Couldn't read the report. Try a clearer photo.", "pdfNeedsAnthropic": "PDF scanning needs a Claude vision provider; upload a photo instead.", "refTextLabel": "Range as printed", - "refTextPlaceholder": "e.g. 3.5 - 5.0" + "refTextPlaceholder": "e.g. 3.5 - 5.0", + "aiUnavailable": "Scanning a report with AI is not available here right now." }, "loadMoreReadings": "Load more readings", "documentsLink": "Lab report documents", @@ -9949,7 +9980,9 @@ "errorNotIndexed": "Read the document first, then extract.", "errorLocalOcrDisabled": "Turn on local OCR in the AI settings first.", "errorProvider": "No AI provider is set up for this.", - "errorTextImageOnly": "Local OCR reads images only — not this file." + "errorTextImageOnly": "Local OCR reads images only — not this file.", + "errorConsent": "Reading documents with AI needs your consent first.", + "errorUnavailable": "Reading documents with AI is not available here right now." }, "summary": { "summarise": "Summarise", @@ -10027,6 +10060,11 @@ "title": "Chat about this document", "openAria": "Ask the Coach about this document", "openButton": "Ask the Coach" + }, + "readingConsent": { + "body": "Reading with AI sends this to your AI service outside this server. Allow HealthLog to send documents, lab reports and medication descriptions for reading?", + "grant": "Allow reading", + "error": "The consent could not be saved. Please try again." } }, "selfDescription": { diff --git a/messages/es.json b/messages/es.json index 287124c44..d0c5cb37e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1312,7 +1312,9 @@ "rateLimit": "Demasiadas solicitudes — espera un momento e inténtalo de nuevo.", "noProvider": "Sin proveedor de IA configurado — configúralo en Ajustes.", "budget": "Se alcanzó el presupuesto diario de IA — inténtalo de nuevo mañana.", - "network": "No se pudo contactar con el proveedor de IA — inténtalo de nuevo." + "network": "No se pudo contactar con el proveedor de IA — inténtalo de nuevo.", + "consent": "Leer tu descripción con IA necesita primero tu consentimiento.", + "unavailable": "Leer descripciones con IA no está disponible aquí ahora mismo." } }, "cadence": { @@ -2444,8 +2446,6 @@ "bmiEmptyTitle": "Añade tu altura para calcular el IMC", "bmiEmptyDescription": "El IMC necesita tu altura. Guárdala en Ajustes → Cuenta.", "bmiEmptyAction": "Abrir ajustes de cuenta", - "noProviderConfigured": "Conecta un proveedor de IA para ver aquí una valoración de tus lecturas.", - "noProviderAction": "Abrir ajustes de IA", "personalRecord": { "badge": "PR", "tooltip": "Récord personal: {value} {unit} el {date}" @@ -2569,10 +2569,6 @@ "emptyDescription": "Genera el resumen de hoy para tener una vista rápida de tus tendencias.", "emptyAction": "Generar resumen", "loadingLabel": "Preparando el resumen", - "noProviderTitle": "Ningún proveedor de IA conectado", - "noProviderDescription": "Apunta HealthLog a un modelo local (Ollama, LM Studio: nada sale de tu red) o usa tu propia clave de OpenAI o Anthropic, y el resumen diario convierte tus propias mediciones en un resumen en lenguaje claro. La configuración lleva alrededor de un minuto.", - "noProviderAction": "Abrir ajustes de IA", - "staleNoProviderHint": "Este briefing no puede actualizarse: no hay ningún proveedor de IA conectado.", "refreshFailedHint": "No se pudo actualizar el briefing: se muestra el anterior.", "retryAction": "Reintentar", "failedTitle": "No se pudo generar el briefing", @@ -2950,7 +2946,15 @@ "interrupted": { "note": "Esta respuesta se interrumpió antes de terminar.", "retry": "Preguntar de nuevo" - } + }, + "historyReadOnlyNote": "El Coach no está disponible ahora mismo, pero aún puedes leer y eliminar tus conversaciones.", + "transcriptYou": "Tú", + "transcriptCoach": "Coach", + "errorConsent": "El Coach necesita tu consentimiento de IA antes de poder responder. Puedes darlo en Ajustes, IA.", + "errorNotPermitted": "El Coach no está disponible dentro de un registro que alguien comparte contigo.", + "errorUnavailable": "El Coach no pudo comprobar su configuración ahora. Inténtalo de nuevo en un momento.", + "errorOperatorOff": "La IA está desactivada en este servidor, así que el Coach no puede responder.", + "errorCoachOff": "El Coach está desactivado. Puedes volver a activarlo en Ajustes, Módulos." }, "relativeJustNow": "ahora mismo", "relativeMinutesAgoOne": "hace {count} minuto", @@ -4383,7 +4387,15 @@ "night": "Un tramo elevado en reposo por la noche; posiblemente algo de tensión." } }, - "navEcg": "ECG" + "navEcg": "ECG", + "sectionLoadError": "No se pudo cargar esta sección.", + "aiSetupHint": { + "title": "La IA es opcional", + "body": "Todo funciona aquí sin IA. Si quieres un resumen diario, notas breves sobre tus mediciones y el Coach, puedes conectar un proveedor de IA.", + "settingsBody": "HealthLog funciona por completo sin IA. Conecta un proveedor abajo si quieres un resumen diario, notas breves sobre tus mediciones y el Coach.", + "action": "Configurar IA", + "dismiss": "Ahora no" + } }, "thresholds": { "defaultLabel": "Por defecto", @@ -5831,12 +5843,30 @@ "activeSince": "Otorgado el {date}. Tus datos de salud pueden enviarse al proveedor configurado para las funciones de IA.", "withdrawn": "Retirado. No se envían datos de salud a ningún proveedor de IA; las funciones de IA permanecen desactivadas.", "withdraw": "Retirar el consentimiento", - "confirmBody": "Las funciones de IA dejan de funcionar de inmediato. No se borra nada de lo ya registrado: usa Datos y privacidad para exportar o eliminar tu historial.", + "confirmBody": "Las funciones de IA se detienen de inmediato y se eliminan las notas que un modelo escribió para ti: notas de estado, el resumen diario, los resúmenes de periodo escritos por un modelo, las líneas de llegada y las notas de entrenamiento. Tus conversaciones con el Coach, lo que el Coach recordaba, tus planes y los resúmenes de tus documentos se conservan, y puedes leerlos o eliminarlos tú mismo.", "confirmWithdraw": "Retirar", "regrantHint": "Si vuelves a otorgarlo, las funciones de IA podrán enviar de nuevo tus datos de salud al proveedor configurado.", "grant": "Otorgar el consentimiento", "grantError": "No se pudo guardar el consentimiento. Inténtalo de nuevo.", - "error": "No se pudo guardar la retirada. Inténtalo de nuevo." + "error": "No se pudo guardar la retirada. Inténtalo de nuevo.", + "documentReading": { + "title": "Consentimiento para leer documentos", + "activeSince": "Dado el {date}. Los documentos, los informes de laboratorio escaneados y las descripciones de medicamentos pueden enviarse a tu servicio de IA para leerlos.", + "confirmBody": "Los documentos, los informes de laboratorio escaneados y las descripciones de medicamentos dejan de enviarse para leerlos. No se elimina nada de lo que ya tienes." + } + }, + "operatorOff": { + "allTitle": "La IA está desactivada en este servidor", + "allBody": "Quien gestiona este servidor ha desactivado la IA. HealthLog sigue funcionando con todos tus datos; las funciones que necesitan IA simplemente no se muestran.", + "someTitle": "Algunas funciones de IA están desactivadas en este servidor", + "someBody": "Quien gestiona este servidor ha desactivado: {switches}. Todo lo demás, incluidos todos tus datos, funciona como siempre.", + "testRefused": "La IA está desactivada en este servidor, así que no hay nada que probar.", + "autoRead": "Leer documentos con IA está desactivado en este servidor." + }, + "coachConversations": { + "title": "Conversaciones con el Coach", + "description": "Abre una conversación para leerla o elimínala.", + "more": "Mostrar más" } }, "dashboard": { @@ -8801,7 +8831,8 @@ "extractFailed": "No se pudo leer el informe. Prueba con una foto más nítida.", "pdfNeedsAnthropic": "El escaneo de PDF necesita un proveedor de visión de Claude; sube una foto en su lugar.", "refTextLabel": "Rango tal como aparece", - "refTextPlaceholder": "p. ej. 3,5 - 5,0" + "refTextPlaceholder": "p. ej. 3,5 - 5,0", + "aiUnavailable": "Escanear un informe con IA no está disponible aquí ahora mismo." }, "loadMoreReadings": "Cargar más mediciones", "documentsLink": "Documentos de resultados", @@ -9949,7 +9980,9 @@ "errorNotIndexed": "Primero lee el documento y luego extrae.", "errorLocalOcrDisabled": "Activa primero el OCR local en los ajustes de IA.", "errorProvider": "No hay ningún proveedor de IA configurado para esto.", - "errorTextImageOnly": "El OCR local solo lee imágenes, no este archivo." + "errorTextImageOnly": "El OCR local solo lee imágenes, no este archivo.", + "errorConsent": "Leer documentos con IA necesita primero tu consentimiento.", + "errorUnavailable": "Leer documentos con IA no está disponible aquí ahora mismo." }, "summary": { "summarise": "Resumir", @@ -10027,6 +10060,11 @@ "title": "Chatea sobre este documento", "openAria": "Pregunta al Coach sobre este documento", "openButton": "Preguntar al Coach" + }, + "readingConsent": { + "body": "Leer con IA envía esto a tu servicio de IA fuera de este servidor. ¿Permitir que HealthLog envíe documentos, informes de laboratorio y descripciones de medicamentos para leerlos?", + "grant": "Permitir lectura", + "error": "No se pudo guardar el consentimiento. Inténtalo de nuevo." } }, "selfDescription": { diff --git a/messages/fr.json b/messages/fr.json index dd59b3df6..e006f315a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1312,7 +1312,9 @@ "rateLimit": "Trop de requêtes — attends un instant et réessaie.", "noProvider": "Aucun fournisseur d'IA configuré — configure-le dans les Paramètres.", "budget": "Budget IA quotidien atteint — réessaie demain.", - "network": "Impossible de joindre le fournisseur d'IA — réessaie." + "network": "Impossible de joindre le fournisseur d'IA — réessaie.", + "consent": "La lecture de votre description par IA nécessite d'abord votre consentement.", + "unavailable": "La lecture de descriptions par IA n'est pas disponible ici pour le moment." } }, "cadence": { @@ -2444,8 +2446,6 @@ "bmiEmptyTitle": "Ajoute ta taille pour l’IMC", "bmiEmptyDescription": "L’IMC nécessite ta taille. Renseigne-la dans Paramètres → Compte.", "bmiEmptyAction": "Ouvrir les paramètres du compte", - "noProviderConfigured": "Connecte un fournisseur d'IA pour voir ici une évaluation de tes mesures.", - "noProviderAction": "Ouvrir les réglages IA", "personalRecord": { "badge": "PR", "tooltip": "Record personnel : {value} {unit} le {date}" @@ -2569,10 +2569,6 @@ "emptyDescription": "Génère le briefing du jour pour obtenir un récap de tes tendances.", "emptyAction": "Générer le briefing", "loadingLabel": "Préparation du briefing", - "noProviderTitle": "Aucun fournisseur d'IA connecté", - "noProviderDescription": "Faites pointer HealthLog vers un modèle local (Ollama, LM Studio — rien ne quitte votre réseau) ou utilisez votre propre clé OpenAI ou Anthropic, et le résumé quotidien transforme vos propres relevés en un résumé en langage clair. La configuration prend environ une minute.", - "noProviderAction": "Ouvrir les réglages IA", - "staleNoProviderHint": "Ce briefing ne peut pas être actualisé — aucun fournisseur d'IA n'est connecté.", "refreshFailedHint": "Impossible d'actualiser le briefing — affichage du précédent.", "retryAction": "Réessayer", "failedTitle": "Impossible de générer le briefing", @@ -2950,7 +2946,15 @@ "interrupted": { "note": "Cette réponse a été interrompue avant la fin.", "retry": "Reposer la question" - } + }, + "historyReadOnlyNote": "Le Coach n'est pas disponible pour le moment, mais vous pouvez toujours lire et supprimer vos conversations.", + "transcriptYou": "Vous", + "transcriptCoach": "Coach", + "errorConsent": "Le Coach a besoin de votre consentement à l'IA avant de pouvoir répondre. Vous pouvez le donner dans Réglages, IA.", + "errorNotPermitted": "Le Coach n'est pas disponible dans un dossier que quelqu'un partage avec vous.", + "errorUnavailable": "Le Coach n'a pas pu vérifier ses réglages pour l'instant. Réessayez dans un instant.", + "errorOperatorOff": "L'IA est désactivée sur ce serveur, le Coach ne peut donc pas répondre.", + "errorCoachOff": "Le Coach est désactivé. Vous pouvez le réactiver dans Réglages, Modules." }, "relativeJustNow": "à l’instant", "relativeMinutesAgoOne": "il y a {count} minute", @@ -4383,7 +4387,15 @@ "night": "Une période élevée au repos cette nuit — peut-être un peu de tension." } }, - "navEcg": "ECG" + "navEcg": "ECG", + "sectionLoadError": "Cette section n'a pas pu être chargée.", + "aiSetupHint": { + "title": "L'IA est facultative", + "body": "Tout fonctionne ici sans IA. Si vous voulez un briefing quotidien, de courtes notes sur vos mesures et le Coach, vous pouvez connecter un fournisseur d'IA.", + "settingsBody": "HealthLog fonctionne entièrement sans IA. Connectez un fournisseur ci-dessous si vous voulez un briefing quotidien, de courtes notes sur vos mesures et le Coach.", + "action": "Configurer l'IA", + "dismiss": "Pas maintenant" + } }, "thresholds": { "defaultLabel": "Par défaut", @@ -5831,12 +5843,30 @@ "activeSince": "Donné le {date}. Tes données de santé peuvent être transmises au fournisseur configuré pour les fonctions IA.", "withdrawn": "Retiré. Aucune donnée de santé n'est transmise à un fournisseur IA ; les fonctions IA restent désactivées.", "withdraw": "Retirer le consentement", - "confirmBody": "Les fonctions IA s'arrêtent immédiatement. Rien de ce qui est déjà enregistré n'est supprimé — utilise Données et confidentialité pour exporter ou effacer ton dossier.", + "confirmBody": "Les fonctions d'IA s'arrêtent immédiatement et les notes qu'un modèle a écrites pour vous sont supprimées : notes d'état, briefing quotidien, résumés de période écrits par un modèle, lignes d'arrivée et notes d'entraînement. Vos conversations avec le Coach, ce que le Coach a retenu, vos plans et les résumés de vos documents sont conservés, et vous pouvez les lire ou les supprimer vous-même.", "confirmWithdraw": "Retirer", "regrantHint": "En le redonnant, les fonctions IA pourront à nouveau transmettre tes données de santé au fournisseur configuré.", "grant": "Donner le consentement", "grantError": "Le consentement n'a pas pu être enregistré. Réessaie.", - "error": "Le retrait n'a pas pu être enregistré. Réessaie." + "error": "Le retrait n'a pas pu être enregistré. Réessaie.", + "documentReading": { + "title": "Consentement à la lecture de documents", + "activeSince": "Donné le {date}. Les documents, les comptes rendus d'analyses numérisés et les descriptions de médicaments peuvent être envoyés à votre service d'IA pour lecture.", + "confirmBody": "Les documents, les comptes rendus d'analyses numérisés et les descriptions de médicaments ne sont plus envoyés pour lecture. Rien de ce que vous avez déjà n'est supprimé." + } + }, + "operatorOff": { + "allTitle": "L'IA est désactivée sur ce serveur", + "allBody": "La personne qui gère ce serveur a désactivé l'IA. HealthLog continue de fonctionner avec toutes vos données ; les fonctions qui ont besoin de l'IA ne sont simplement pas affichées.", + "someTitle": "Certaines fonctions d'IA sont désactivées sur ce serveur", + "someBody": "La personne qui gère ce serveur a désactivé : {switches}. Tout le reste, y compris toutes vos données, fonctionne normalement.", + "testRefused": "L'IA est désactivée sur ce serveur, il n'y a donc rien à tester.", + "autoRead": "La lecture de documents par IA est désactivée sur ce serveur." + }, + "coachConversations": { + "title": "Conversations avec le Coach", + "description": "Ouvrez une conversation pour la lire, ou supprimez-la.", + "more": "Afficher plus" } }, "dashboard": { @@ -8801,7 +8831,8 @@ "extractFailed": "Impossible de lire le compte rendu. Essayez une photo plus nette.", "pdfNeedsAnthropic": "Le scan PDF nécessite un fournisseur de vision Claude ; importez plutôt une photo.", "refTextLabel": "Plage telle qu'imprimée", - "refTextPlaceholder": "p. ex. 3,5 - 5,0" + "refTextPlaceholder": "p. ex. 3,5 - 5,0", + "aiUnavailable": "La numérisation d'un compte rendu par IA n'est pas disponible ici pour le moment." }, "loadMoreReadings": "Charger plus de mesures", "documentsLink": "Documents de résultats", @@ -9949,7 +9980,9 @@ "errorNotIndexed": "Lisez d’abord le document, puis extrayez.", "errorLocalOcrDisabled": "Activez d'abord l'OCR local dans les paramètres d'IA.", "errorProvider": "Aucun fournisseur d'IA n'est configuré pour cela.", - "errorTextImageOnly": "L'OCR local ne lit que les images, pas ce fichier." + "errorTextImageOnly": "L'OCR local ne lit que les images, pas ce fichier.", + "errorConsent": "La lecture de documents par IA nécessite d'abord votre consentement.", + "errorUnavailable": "La lecture de documents par IA n'est pas disponible ici pour le moment." }, "summary": { "summarise": "Résumer", @@ -10027,6 +10060,11 @@ "title": "Discuter de ce document", "openAria": "Interroger le Coach sur ce document", "openButton": "Demander au Coach" + }, + "readingConsent": { + "body": "La lecture par IA envoie ceci à votre service d'IA hors de ce serveur. Autoriser HealthLog à envoyer des documents, des résultats d'analyses et des descriptions de médicaments pour lecture ?", + "grant": "Autoriser la lecture", + "error": "Le consentement n'a pas pu être enregistré. Veuillez réessayer." } }, "selfDescription": { diff --git a/messages/it.json b/messages/it.json index 6ab0f72c0..51dc640a9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1312,7 +1312,9 @@ "rateLimit": "Troppe richieste — attendi un momento e riprova.", "noProvider": "Nessun provider IA configurato — configurane uno in Impostazioni.", "budget": "Budget IA giornaliero raggiunto — riprova domani.", - "network": "Impossibile contattare il provider IA — riprova." + "network": "Impossibile contattare il provider IA — riprova.", + "consent": "La lettura della tua descrizione con IA richiede prima il tuo consenso.", + "unavailable": "La lettura delle descrizioni con IA non è disponibile qui al momento." } }, "cadence": { @@ -2444,8 +2446,6 @@ "bmiEmptyTitle": "Aggiungi l’altezza per il BMI", "bmiEmptyDescription": "Per il BMI serve la tua altezza. Salvala in Impostazioni → Account.", "bmiEmptyAction": "Apri impostazioni account", - "noProviderConfigured": "Collega un provider IA per vedere qui una valutazione delle tue misurazioni.", - "noProviderAction": "Apri impostazioni IA", "personalRecord": { "badge": "PR", "tooltip": "Record personale: {value} {unit} il {date}" @@ -2569,10 +2569,6 @@ "emptyDescription": "Genera il briefing di oggi per avere un riepilogo dei tuoi trend.", "emptyAction": "Genera briefing", "loadingLabel": "Preparazione del briefing", - "noProviderTitle": "Nessun provider IA collegato", - "noProviderDescription": "Punta HealthLog verso un modello locale (Ollama, LM Studio: nulla lascia la tua rete) o usa la tua chiave OpenAI o Anthropic, e il riepilogo quotidiano trasforma le tue misurazioni in un riassunto in linguaggio chiaro. La configurazione richiede circa un minuto.", - "noProviderAction": "Apri impostazioni IA", - "staleNoProviderHint": "Questo briefing non può aggiornarsi: nessun provider IA è collegato.", "refreshFailedHint": "Impossibile aggiornare il briefing: viene mostrato l'ultimo.", "retryAction": "Riprova", "failedTitle": "Impossibile generare il briefing", @@ -2950,7 +2946,15 @@ "interrupted": { "note": "Questa risposta è stata interrotta prima di essere completata.", "retry": "Chiedi di nuovo" - } + }, + "historyReadOnlyNote": "Il Coach non è disponibile al momento, ma puoi ancora leggere ed eliminare le tue conversazioni.", + "transcriptYou": "Tu", + "transcriptCoach": "Coach", + "errorConsent": "Il Coach ha bisogno del tuo consenso all'IA prima di poter rispondere. Puoi darlo in Impostazioni, IA.", + "errorNotPermitted": "Il Coach non è disponibile in una cartella che qualcuno condivide con te.", + "errorUnavailable": "Il Coach non è riuscito a controllare le sue impostazioni. Riprova tra un momento.", + "errorOperatorOff": "L'IA è disattivata su questo server, quindi il Coach non può rispondere.", + "errorCoachOff": "Il Coach è disattivato. Puoi riattivarlo in Impostazioni, Moduli." }, "relativeJustNow": "proprio ora", "relativeMinutesAgoOne": "{count} minuto fa", @@ -4383,7 +4387,15 @@ "night": "Un tratto elevato a riposo durante la notte — forse un po' di tensione." } }, - "navEcg": "ECG" + "navEcg": "ECG", + "sectionLoadError": "Non è stato possibile caricare questa sezione.", + "aiSetupHint": { + "title": "L'IA è facoltativa", + "body": "Qui tutto funziona anche senza IA. Se vuoi un riepilogo giornaliero, brevi note sulle tue misurazioni e il Coach, puoi collegare un fornitore di IA.", + "settingsBody": "HealthLog funziona completamente senza IA. Collega un fornitore qui sotto se vuoi un riepilogo giornaliero, brevi note sulle tue misurazioni e il Coach.", + "action": "Configura IA", + "dismiss": "Non ora" + } }, "thresholds": { "defaultLabel": "Predefinito", @@ -5831,12 +5843,30 @@ "activeSince": "Concesso il {date}. I tuoi dati sanitari possono essere inviati al provider configurato per le funzioni IA.", "withdrawn": "Revocato. Nessun dato sanitario viene inviato a un provider IA; le funzioni IA restano disattivate.", "withdraw": "Revoca il consenso", - "confirmBody": "Le funzioni IA si fermano subito. Nulla di quanto già registrato viene eliminato — usa Dati e privacy per esportare o cancellare il tuo archivio.", + "confirmBody": "Le funzioni di IA si fermano subito e le note che un modello ha scritto per te vengono eliminate: note di stato, il riepilogo giornaliero, i riepiloghi di periodo scritti da un modello, le righe di arrivo e le note sugli allenamenti. Le tue conversazioni con il Coach, ciò che il Coach ricordava, i tuoi piani e i riepiloghi dei tuoi documenti restano, e puoi leggerli o eliminarli tu stesso.", "confirmWithdraw": "Revoca", "regrantHint": "Concedendolo di nuovo, le funzioni IA potranno inviare ancora i tuoi dati sanitari al provider configurato.", "grant": "Concedi il consenso", "grantError": "Non è stato possibile salvare il consenso. Riprova.", - "error": "Non è stato possibile salvare la revoca. Riprova." + "error": "Non è stato possibile salvare la revoca. Riprova.", + "documentReading": { + "title": "Consenso alla lettura dei documenti", + "activeSince": "Dato il {date}. Documenti, referti di laboratorio scansionati e descrizioni di farmaci possono essere inviati al tuo servizio di IA per la lettura.", + "confirmBody": "Documenti, referti di laboratorio scansionati e descrizioni di farmaci non vengono più inviati per la lettura. Nulla di ciò che hai già viene eliminato." + } + }, + "operatorOff": { + "allTitle": "L'IA è disattivata su questo server", + "allBody": "Chi gestisce questo server ha disattivato l'IA. HealthLog continua a funzionare con tutti i tuoi dati; le funzioni che richiedono l'IA semplicemente non vengono mostrate.", + "someTitle": "Alcune funzioni di IA sono disattivate su questo server", + "someBody": "Chi gestisce questo server ha disattivato: {switches}. Tutto il resto, compresi tutti i tuoi dati, funziona come sempre.", + "testRefused": "L'IA è disattivata su questo server, quindi non c'è nulla da testare.", + "autoRead": "La lettura dei documenti con IA è disattivata su questo server." + }, + "coachConversations": { + "title": "Conversazioni con il Coach", + "description": "Apri una conversazione per leggerla oppure eliminala.", + "more": "Mostra altro" } }, "dashboard": { @@ -8801,7 +8831,8 @@ "extractFailed": "Impossibile leggere il referto. Prova con una foto più nitida.", "pdfNeedsAnthropic": "La scansione PDF richiede un provider di visione Claude; carica invece una foto.", "refTextLabel": "Intervallo come stampato", - "refTextPlaceholder": "es. 3,5 - 5,0" + "refTextPlaceholder": "es. 3,5 - 5,0", + "aiUnavailable": "La scansione di un referto con IA non è disponibile qui al momento." }, "loadMoreReadings": "Carica altre misurazioni", "documentsLink": "Documenti dei referti", @@ -9949,7 +9980,9 @@ "errorNotIndexed": "Leggi prima il documento, poi estrai.", "errorLocalOcrDisabled": "Attiva prima l'OCR locale nelle impostazioni IA.", "errorProvider": "Nessun provider IA è configurato per questo.", - "errorTextImageOnly": "L'OCR locale legge solo immagini, non questo file." + "errorTextImageOnly": "L'OCR locale legge solo immagini, non questo file.", + "errorConsent": "La lettura dei documenti con IA richiede prima il tuo consenso.", + "errorUnavailable": "La lettura dei documenti con IA non è disponibile qui al momento." }, "summary": { "summarise": "Riassumi", @@ -10027,6 +10060,11 @@ "title": "Chatta su questo documento", "openAria": "Chiedi al Coach su questo documento", "openButton": "Chiedi al Coach" + }, + "readingConsent": { + "body": "La lettura con IA invia questo contenuto al tuo servizio di IA fuori da questo server. Consentire a HealthLog di inviare documenti, referti di laboratorio e descrizioni di farmaci per la lettura?", + "grant": "Consenti lettura", + "error": "Non è stato possibile salvare il consenso. Riprova." } }, "selfDescription": { diff --git a/messages/ko.json b/messages/ko.json index 48747e8c5..7fc71b76d 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -1312,7 +1312,9 @@ "rateLimit": "요청이 너무 많아요 — 잠시 후 다시 시도해 주세요.", "noProvider": "AI 제공자가 설정되지 않았어요 — 설정에서 등록해 주세요.", "budget": "오늘 AI 사용 한도를 다 썼어요 — 내일 다시 시도해 주세요.", - "network": "AI 제공자에 연결하지 못했어요 — 다시 시도해 주세요." + "network": "AI 제공자에 연결하지 못했어요 — 다시 시도해 주세요.", + "consent": "AI로 설명을 읽으려면 먼저 동의가 필요합니다.", + "unavailable": "지금 여기에서는 AI로 설명을 읽을 수 없습니다." } }, "cadence": { @@ -2444,8 +2446,6 @@ "bmiEmptyTitle": "BMI를 보려면 키를 입력해 주세요", "bmiEmptyDescription": "BMI에는 키가 필요해요. 설정 → 계정에서 추가해 주세요.", "bmiEmptyAction": "계정 설정 열기", - "noProviderConfigured": "AI 제공자를 연결하면 여기에서 측정값 평가를 볼 수 있어요.", - "noProviderAction": "AI 설정 열기", "personalRecord": { "badge": "신기록", "tooltip": "개인 최고 기록: {date} {value}{unit}" @@ -2569,10 +2569,6 @@ "emptyDescription": "오늘 브리핑을 만들면 추이를 이야기로 읽어 줘요.", "emptyAction": "브리핑 생성", "loadingLabel": "오늘 브리핑을 정리하는 중", - "noProviderTitle": "연결된 AI 제공자가 없어요", - "noProviderDescription": "HealthLog를 로컬 모델(Ollama, LM Studio — 네트워크 밖으로 나가지 않아요)에 연결하거나 직접 쓰는 OpenAI·Anthropic 키를 넣으면, 데일리 브리핑이 내 측정값을 쉬운 말 요약으로 바꿔 줘요. 설정은 1분쯤 걸려요.", - "noProviderAction": "AI 설정 열기", - "staleNoProviderHint": "연결된 AI 제공자가 없어서 이 브리핑을 새로 고칠 수 없어요.", "refreshFailedHint": "브리핑을 새로 고치지 못해서 마지막 브리핑을 보여 줘요.", "retryAction": "다시 시도", "failedTitle": "브리핑을 만들지 못했어요", @@ -2950,7 +2946,15 @@ "interrupted": { "note": "이 답변은 끝나기 전에 중단됐어요.", "retry": "다시 물어보기" - } + }, + "historyReadOnlyNote": "지금은 코치를 사용할 수 없지만 대화는 계속 읽고 삭제할 수 있습니다.", + "transcriptYou": "나", + "transcriptCoach": "코치", + "errorConsent": "코치가 답변하려면 AI 동의가 필요합니다. 설정의 AI에서 동의할 수 있습니다.", + "errorNotPermitted": "다른 사람이 공유한 기록 안에서는 코치를 사용할 수 없습니다.", + "errorUnavailable": "코치가 지금 설정을 확인하지 못했습니다. 잠시 후 다시 시도해 주세요.", + "errorOperatorOff": "이 서버에서 AI가 꺼져 있어 코치가 답변할 수 없습니다.", + "errorCoachOff": "코치가 꺼져 있습니다. 설정의 모듈에서 다시 켤 수 있습니다." }, "relativeJustNow": "방금", "relativeMinutesAgoOne": "{count}분 전", @@ -4383,7 +4387,15 @@ "night": "밤사이 안정 시 심박수가 높은 구간이 이어졌어요 — 긴장이 있었을 수 있어요." } }, - "navEcg": "심전도" + "navEcg": "심전도", + "sectionLoadError": "이 섹션을 불러오지 못했습니다.", + "aiSetupHint": { + "title": "AI는 선택 사항입니다", + "body": "여기의 모든 기능은 AI 없이도 작동합니다. 일일 브리핑, 측정값에 대한 짧은 메모, 코치를 원하시면 AI 제공자를 연결할 수 있습니다.", + "settingsBody": "HealthLog는 AI 없이도 모든 기능이 작동합니다. 일일 브리핑, 측정값에 대한 짧은 메모, 코치를 원하시면 아래에서 제공자를 연결하세요.", + "action": "AI 설정", + "dismiss": "나중에" + } }, "thresholds": { "defaultLabel": "기본값", @@ -5831,12 +5843,30 @@ "activeSince": "{date}에 동의했어요. AI 기능을 위해 건강 데이터가 설정한 제공자에게 전송될 수 있어요.", "withdrawn": "동의를 철회했어요. 건강 데이터를 AI 제공자에게 보내지 않고, AI 기능도 꺼진 상태로 유지돼요.", "withdraw": "동의 철회", - "confirmBody": "AI 기능이 바로 멈춰요. 이미 기록한 내용은 지워지지 않아요 — 내보내거나 삭제하려면 데이터 및 개인정보를 이용하세요.", + "confirmBody": "AI 기능이 즉시 중지되고 모델이 작성한 메모가 삭제됩니다: 상태 메모, 일일 브리핑, 모델이 작성한 기간 요약, 새 측정값 알림 문장, 운동 메모. 코치 대화, 코치가 기억한 내용, 계획, 문서 요약은 그대로 남으며 직접 읽거나 삭제할 수 있습니다.", "confirmWithdraw": "철회", "regrantHint": "다시 동의하면 AI 기능이 건강 데이터를 설정한 제공자에게 보낼 수 있어요.", "grant": "동의하기", "grantError": "동의를 저장하지 못했어요. 다시 시도해 주세요.", - "error": "철회를 저장하지 못했어요. 다시 시도해 주세요." + "error": "철회를 저장하지 못했어요. 다시 시도해 주세요.", + "documentReading": { + "title": "문서 읽기 동의", + "activeSince": "{date}에 동의함. 문서, 스캔한 검사 결과지, 약 설명이 읽기용으로 AI 서비스에 전송될 수 있습니다.", + "confirmBody": "문서, 스캔한 검사 결과지, 약 설명이 더 이상 읽기용으로 전송되지 않습니다. 이미 가지고 있는 것은 삭제되지 않습니다." + } + }, + "operatorOff": { + "allTitle": "이 서버에서 AI가 꺼져 있습니다", + "allBody": "이 서버를 운영하는 사람이 AI를 껐습니다. HealthLog는 모든 데이터로 계속 작동하며, AI가 필요한 기능만 표시되지 않습니다.", + "someTitle": "이 서버에서 일부 AI 기능이 꺼져 있습니다", + "someBody": "이 서버를 운영하는 사람이 다음을 껐습니다: {switches}. 모든 데이터를 포함한 나머지는 평소처럼 작동합니다.", + "testRefused": "이 서버에서 AI가 꺼져 있어 테스트할 대상이 없습니다.", + "autoRead": "이 서버에서 AI 문서 읽기가 꺼져 있습니다." + }, + "coachConversations": { + "title": "코치 대화", + "description": "대화를 열어 읽거나 삭제하세요.", + "more": "더 보기" } }, "dashboard": { @@ -8801,7 +8831,8 @@ "extractFailed": "결과지를 읽지 못했어요. 더 선명한 사진으로 시도해 주세요.", "pdfNeedsAnthropic": "PDF 스캔에는 Claude 비전 제공자가 필요해요. 대신 사진을 올려 주세요.", "refTextLabel": "적힌 그대로의 범위", - "refTextPlaceholder": "예: 3.5 - 5.0" + "refTextPlaceholder": "예: 3.5 - 5.0", + "aiUnavailable": "지금 여기에서는 AI로 결과지를 스캔할 수 없습니다." }, "loadMoreReadings": "측정값 더 보기", "documentsLink": "검사 결과지 문서", @@ -9949,7 +9980,9 @@ "errorNotIndexed": "문서를 먼저 읽은 뒤에 추출해 주세요.", "errorLocalOcrDisabled": "AI 설정에서 로컬 OCR을 먼저 켜 주세요.", "errorProvider": "이 작업에 쓸 AI 제공자가 설정돼 있지 않아요.", - "errorTextImageOnly": "로컬 OCR은 이미지만 읽어요 — 이 파일은 읽을 수 없어요." + "errorTextImageOnly": "로컬 OCR은 이미지만 읽어요 — 이 파일은 읽을 수 없어요.", + "errorConsent": "AI로 문서를 읽으려면 먼저 동의가 필요합니다.", + "errorUnavailable": "지금 여기에서는 AI로 문서를 읽을 수 없습니다." }, "summary": { "summarise": "요약하기", @@ -10027,6 +10060,11 @@ "title": "이 문서에 대해 대화", "openAria": "이 문서에 대해 코치에게 묻기", "openButton": "코치에게 묻기" + }, + "readingConsent": { + "body": "AI로 읽으면 이 내용이 이 서버 밖의 AI 서비스로 전송됩니다. HealthLog가 문서, 검사 결과지, 약 설명을 읽기용으로 보내도록 허용하시겠습니까?", + "grant": "읽기 허용", + "error": "동의를 저장하지 못했습니다. 다시 시도해 주세요." } }, "selfDescription": { diff --git a/messages/pl.json b/messages/pl.json index 6e60f460d..8fc0ddc6e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1312,7 +1312,9 @@ "rateLimit": "Zbyt wiele żądań — poczekaj chwilę i spróbuj ponownie.", "noProvider": "Brak skonfigurowanego dostawcy AI — skonfiguruj go w Ustawieniach.", "budget": "Osiągnięto dzienny limit AI — spróbuj jutro.", - "network": "Nie udało się połączyć z dostawcą AI — spróbuj ponownie." + "network": "Nie udało się połączyć z dostawcą AI — spróbuj ponownie.", + "consent": "Odczyt Twojego opisu przez AI wymaga najpierw Twojej zgody.", + "unavailable": "Odczyt opisów przez AI jest tu teraz niedostępny." } }, "cadence": { @@ -2444,8 +2446,6 @@ "bmiEmptyTitle": "Dodaj wzrost, aby obliczyć BMI", "bmiEmptyDescription": "BMI wymaga twojego wzrostu. Zapisz go w Ustawieniach → Konto.", "bmiEmptyAction": "Otwórz ustawienia konta", - "noProviderConfigured": "Połącz dostawcę AI, aby zobaczyć tutaj ocenę swoich pomiarów.", - "noProviderAction": "Otwórz ustawienia AI", "personalRecord": { "badge": "PR", "tooltip": "Rekord osobisty: {value} {unit} dnia {date}" @@ -2569,10 +2569,6 @@ "emptyDescription": "Wygeneruj dzisiejszy briefing, aby zobaczyć podsumowanie trendów.", "emptyAction": "Wygeneruj briefing", "loadingLabel": "Przygotowanie briefingu", - "noProviderTitle": "Brak połączonego dostawcy AI", - "noProviderDescription": "Skieruj HealthLog na model lokalny (Ollama, LM Studio — nic nie opuszcza Twojej sieci) lub podaj własny klucz OpenAI albo Anthropic, a codzienne podsumowanie zamieni Twoje pomiary w zrozumiały opis. Konfiguracja zajmuje około minuty.", - "noProviderAction": "Otwórz ustawienia AI", - "staleNoProviderHint": "Tego briefingu nie można odświeżyć — nie połączono żadnego dostawcy AI.", "refreshFailedHint": "Nie udało się odświeżyć briefingu — pokazujemy poprzedni.", "retryAction": "Spróbuj ponownie", "failedTitle": "Nie udało się wygenerować briefingu", @@ -2950,7 +2946,15 @@ "interrupted": { "note": "Ta odpowiedź została przerwana przed ukończeniem.", "retry": "Zapytaj ponownie" - } + }, + "historyReadOnlyNote": "Coach jest teraz niedostępny, ale nadal możesz czytać i usuwać swoje rozmowy.", + "transcriptYou": "Ty", + "transcriptCoach": "Coach", + "errorConsent": "Coach potrzebuje Twojej zgody na AI, zanim odpowie. Możesz ją wyrazić w Ustawieniach, AI.", + "errorNotPermitted": "Coach nie jest dostępny w kartotece, którą ktoś Ci udostępnił.", + "errorUnavailable": "Coach nie mógł teraz sprawdzić swoich ustawień. Spróbuj ponownie za chwilę.", + "errorOperatorOff": "AI jest wyłączone na tym serwerze, więc Coach nie może odpowiedzieć.", + "errorCoachOff": "Coach jest wyłączony. Możesz go ponownie włączyć w Ustawieniach, Moduły." }, "relativeJustNow": "przed chwilą", "relativeMinutesAgoOne": "{count} minutę temu", @@ -4383,7 +4387,15 @@ "night": "Utrzymujący się podwyższony spoczynek w nocy — możliwe napięcie." } }, - "navEcg": "EKG" + "navEcg": "EKG", + "sectionLoadError": "Nie udało się wczytać tej sekcji.", + "aiSetupHint": { + "title": "AI jest opcjonalne", + "body": "Wszystko tutaj działa bez AI. Jeśli chcesz codzienne podsumowanie, krótkie notatki o pomiarach i Coacha, możesz podłączyć dostawcę AI.", + "settingsBody": "HealthLog działa w pełni bez AI. Podłącz dostawcę poniżej, jeśli chcesz codzienne podsumowanie, krótkie notatki o pomiarach i Coacha.", + "action": "Skonfiguruj AI", + "dismiss": "Nie teraz" + } }, "thresholds": { "defaultLabel": "Domyślne", @@ -5831,12 +5843,30 @@ "activeSince": "Udzielona {date}. Twoje dane zdrowotne mogą być wysyłane do skonfigurowanego dostawcy na potrzeby funkcji AI.", "withdrawn": "Wycofana. Żadne dane zdrowotne nie są wysyłane do dostawcy AI; funkcje AI pozostają wyłączone.", "withdraw": "Wycofaj zgodę", - "confirmBody": "Funkcje AI przestają działać natychmiast. Nic z już zapisanych danych nie zostaje usunięte — użyj sekcji Dane i prywatność, aby wyeksportować lub usunąć swoją historię.", + "confirmBody": "Funkcje AI zatrzymują się od razu, a notatki napisane dla Ciebie przez model zostają usunięte: notatki o stanie, codzienne podsumowanie, podsumowania okresu napisane przez model, linie po nowym pomiarze i notatki o treningach. Twoje rozmowy z Coachem, to, co zapamiętał Coach, Twoje plany i podsumowania dokumentów zostają, a Ty możesz je sam przeczytać lub usunąć.", "confirmWithdraw": "Wycofaj", "regrantHint": "Po ponownym udzieleniu zgody funkcje AI znów będą mogły wysyłać Twoje dane zdrowotne do skonfigurowanego dostawcy.", "grant": "Udziel zgody", "grantError": "Nie udało się zapisać zgody. Spróbuj ponownie.", - "error": "Nie udało się zapisać wycofania. Spróbuj ponownie." + "error": "Nie udało się zapisać wycofania. Spróbuj ponownie.", + "documentReading": { + "title": "Zgoda na odczyt dokumentów", + "activeSince": "Udzielona {date}. Dokumenty, zeskanowane wyniki badań i opisy leków mogą być wysyłane do Twojej usługi AI do odczytu.", + "confirmBody": "Dokumenty, zeskanowane wyniki badań i opisy leków nie są już wysyłane do odczytu. Nic, co już masz, nie zostaje usunięte." + } + }, + "operatorOff": { + "allTitle": "AI jest wyłączone na tym serwerze", + "allBody": "Osoba prowadząca ten serwer wyłączyła AI. HealthLog nadal działa ze wszystkimi Twoimi danymi; funkcje wymagające AI po prostu się nie wyświetlają.", + "someTitle": "Niektóre funkcje AI są wyłączone na tym serwerze", + "someBody": "Osoba prowadząca ten serwer wyłączyła: {switches}. Wszystko inne, w tym wszystkie Twoje dane, działa jak zwykle.", + "testRefused": "AI jest wyłączone na tym serwerze, więc nie ma czego testować.", + "autoRead": "Odczyt dokumentów przez AI jest wyłączony na tym serwerze." + }, + "coachConversations": { + "title": "Rozmowy z Coachem", + "description": "Otwórz rozmowę, aby ją przeczytać, albo ją usuń.", + "more": "Pokaż więcej" } }, "dashboard": { @@ -8801,7 +8831,8 @@ "extractFailed": "Nie udało się odczytać wyniku. Spróbuj wyraźniejszego zdjęcia.", "pdfNeedsAnthropic": "Skanowanie PDF wymaga dostawcy wizji Claude; prześlij zamiast tego zdjęcie.", "refTextLabel": "Zakres tak jak wydrukowano", - "refTextPlaceholder": "np. 3,5 - 5,0" + "refTextPlaceholder": "np. 3,5 - 5,0", + "aiUnavailable": "Skanowanie wyniku przez AI jest tu teraz niedostępne." }, "loadMoreReadings": "Wczytaj więcej pomiarów", "documentsLink": "Dokumenty wyników badań", @@ -9949,7 +9980,9 @@ "errorNotIndexed": "Najpierw odczytaj dokument, potem wyodrębnij.", "errorLocalOcrDisabled": "Najpierw włącz lokalne OCR w ustawieniach AI.", "errorProvider": "Nie skonfigurowano dostawcy AI do tego celu.", - "errorTextImageOnly": "Lokalne OCR odczytuje tylko obrazy, nie ten plik." + "errorTextImageOnly": "Lokalne OCR odczytuje tylko obrazy, nie ten plik.", + "errorConsent": "Odczyt dokumentów przez AI wymaga najpierw Twojej zgody.", + "errorUnavailable": "Odczyt dokumentów przez AI jest tu teraz niedostępny." }, "summary": { "summarise": "Podsumuj", @@ -10027,6 +10060,11 @@ "title": "Porozmawiaj o tym dokumencie", "openAria": "Zapytaj Coacha o ten dokument", "openButton": "Zapytaj Coacha" + }, + "readingConsent": { + "body": "Odczyt przez AI wysyła to do Twojej usługi AI poza tym serwerem. Czy HealthLog może wysyłać dokumenty, wyniki badań i opisy leków do odczytu?", + "grant": "Zezwól na odczyt", + "error": "Nie udało się zapisać zgody. Spróbuj ponownie." } }, "selfDescription": { diff --git a/playwright.config.ts b/playwright.config.ts index 57cb11be7..626d35b11 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -251,6 +251,11 @@ export default defineConfig({ // under the first mid-assertion. It sets the mobile width itself for // the add menu, so it runs in one project. "modules-off.spec.ts", + // The AI-optional account journeys withdraw their account's consent + // and delete its stored Coach rows; a second project would reset the + // account under the first. They prove server state through stable + // data-slots, not a mobile layout, so they run in one project. + "ai-optional-account.spec.ts", ], use: { // Pixel 5 — Chromium-based mobile profile so CI only needs diff --git a/src/__tests__/ai-capability-surface-guard.test.ts b/src/__tests__/ai-capability-surface-guard.test.ts new file mode 100644 index 000000000..74dff38d1 --- /dev/null +++ b/src/__tests__/ai-capability-surface-guard.test.ts @@ -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 = { + "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 = { + // `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); + }); +}); diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index f1c58ba81..ab20989db 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -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": { diff --git a/src/__tests__/v1341-locale-parity.test.ts b/src/__tests__/v1341-locale-parity.test.ts index 10d6e7733..bdd0570da 100644 --- a/src/__tests__/v1341-locale-parity.test.ts +++ b/src/__tests__/v1341-locale-parity.test.ts @@ -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", diff --git a/src/app/coach/__tests__/page-interrupted-resume.test.tsx b/src/app/coach/__tests__/page-interrupted-resume.test.tsx index c01191e27..f3d22ebfc 100644 --- a/src/app/coach/__tests__/page-interrupted-resume.test.tsx +++ b/src/app/coach/__tests__/page-interrupted-resume.test.tsx @@ -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 }), diff --git a/src/app/coach/__tests__/page-launch-params.test.tsx b/src/app/coach/__tests__/page-launch-params.test.tsx index 3f56c28c8..82b79143f 100644 --- a/src/app/coach/__tests__/page-launch-params.test.tsx +++ b/src/app/coach/__tests__/page-launch-params.test.tsx @@ -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 }), diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx index 1d276bc48..1d3048e6d 100644 --- a/src/app/coach/conversations/page.tsx +++ b/src/app/coach/conversations/page.tsx @@ -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(null); const router = useRouter(); const [filter, setFilter] = useState(""); // 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=` open mechanism on the Coach page. router.push(`/coach?c=${id}`); } @@ -175,18 +183,24 @@ function CoachConversationsBody() { {t("insights.coach.historyTitle")} } + description={ + readOnly ? t("insights.coach.historyReadOnlyNote") : undefined + } actions={ // Surface the sibling Plans ledger — otherwise it is reachable - // only from the composer's `+` menu. - + // only from the composer's `+` menu. Plans need the Coach, so the + // link stays away while it is unavailable. + readOnly ? undefined : ( + + ) } />
@@ -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} > {/* v1.29.x (S7) — badge fenced threads (they run on @@ -294,7 +309,9 @@ function CoachConversationsBody() { {formatRelativeTime(c.updatedAt, t, locale)} - + {readOnly ? null : ( + + )} + {readOnly && openId === c.id ? ( +
+ +
+ ) : null} ); })} @@ -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 (
- +
); } diff --git a/src/app/coach/page-client.tsx b/src/app/coach/page-client.tsx index cef46e764..6ac9fbfc9 100644 --- a/src/app/coach/page-client.tsx +++ b/src/app/coach/page-client.tsx @@ -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 `` / ``: - // 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 diff --git a/src/app/coach/plans/page.tsx b/src/app/coach/plans/page.tsx index daa23c41d..04ab0dec6 100644 --- a/src/app/coach/plans/page.tsx +++ b/src/app/coach/plans/page.tsx @@ -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 (
{t(entry.nameKey)} - + {unknown ? null : }

{t(entry.sourceKey)}

- {isPresent && entry.href ? ( + {unknown ? null : isPresent && entry.href ? ( + {grant.isError ? ( +

+ {t("documents.readingConsent.error")} +

+ ) : null} +
+ ); +} diff --git a/src/components/cycle/__tests__/cycle-ring-tile.test.tsx b/src/components/cycle/__tests__/cycle-ring-tile.test.tsx index 192383f05..2085f7904 100644 --- a/src/components/cycle/__tests__/cycle-ring-tile.test.tsx +++ b/src/components/cycle/__tests__/cycle-ring-tile.test.tsx @@ -48,7 +48,7 @@ vi.mock("../use-cycle", async () => { await vi.importActual("../use-cycle"); return { ...actual, - useCycleCalendar: () => calendarState.current, + useCycleRingCalendar: () => calendarState.current, }; }); diff --git a/src/components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx b/src/components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx new file mode 100644 index 000000000..b0b036a09 --- /dev/null +++ b/src/components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx @@ -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 {String(useCycleRingHasDial(enabled))}; +} + +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( + + + , + ); + return html === "true"; +} + +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); + }); +}); diff --git a/src/components/cycle/cycle-ring-tile.tsx b/src/components/cycle/cycle-ring-tile.tsx index 9b0342ab2..486f06578 100644 --- a/src/components/cycle/cycle-ring-tile.tsx +++ b/src/components/cycle/cycle-ring-tile.tsx @@ -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. diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts index d041dabec..39b2d0684 100644 --- a/src/components/cycle/use-cycle.ts +++ b/src/components/cycle/use-cycle.ts @@ -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), diff --git a/src/components/documents/__tests__/document-ai-error-key.test.ts b/src/components/documents/__tests__/document-ai-error-key.test.ts new file mode 100644 index 000000000..190dbd7ae --- /dev/null +++ b/src/components/documents/__tests__/document-ai-error-key.test.ts @@ -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", + ); + }); +}); diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index 1ee941f67..c1b216982 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -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" ? ( + + ) : null} + + {canManageDocuments && documentAi.available && ( ({ + 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( + + + {node} + + , + ); +} + +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: () => , + proof: 'data-slot="coach-launch-inline"', + }, + { + name: "AskCoachAction", + mount: () => , + proof: 'data-slot="ask-coach-action"', + }, + { + name: "LayoutCoachFab", + mount: () => , + 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: () => , 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"); + }); + } + } +}); diff --git a/src/components/insights/__tests__/daily-briefing.test.tsx b/src/components/insights/__tests__/daily-briefing.test.tsx index 1b2290285..80e3039fc 100644 --- a/src/components/insights/__tests__/daily-briefing.test.tsx +++ b/src/components/insights/__tests__/daily-briefing.test.tsx @@ -268,28 +268,18 @@ describe("", () => { 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( - {}} noProvider />, + {}} />, ); - 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(, "de"); - expect(html).toContain("Kein KI-Anbieter verbunden"); - }); - - it("prefers the briefing content over the no-provider hint when both exist", () => { - const html = render(); - 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("", () => { expect(html).not.toContain('data-slot="daily-briefing-refresh-failed"'); }); - it("suppresses the refresh-failed hint when no provider is connected", () => { - const html = render( - {}} - 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( ({ + useAiCapability: () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), +})); vi.mock("next/dynamic", () => ({ default: () => { const Stub = () =>
; diff --git a/src/components/insights/__tests__/insight-status-card-capability-gate.test.tsx b/src/components/insights/__tests__/insight-status-card-capability-gate.test.tsx new file mode 100644 index 000000000..f2ebb026b --- /dev/null +++ b/src/components/insights/__tests__/insight-status-card-capability-gate.test.tsx @@ -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( + {node}, + ); +} + +beforeEach(() => { + capability.current = AI_AVAILABLE; + capability.asked = []; +}); + +describe(" — statusText capability", () => { + it("renders the note while status notes are available", () => { + const html = render(); + 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()).toBe(""); + }); + } + + it("never renders a connect-a-provider state", () => { + const html = render( + , + ); + 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( + , + ); + expect(html).toContain("Your pulse is stable"); + }); +}); diff --git a/src/components/insights/__tests__/insight-status-card-flag-gate.test.tsx b/src/components/insights/__tests__/insight-status-card-flag-gate.test.tsx deleted file mode 100644 index b6018a164..000000000 --- a/src/components/insights/__tests__/insight-status-card-flag-gate.test.tsx +++ /dev/null @@ -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 `` returns `null` when - * the operator has disabled the `insightStatus` surface. Renders - * normally otherwise. - */ -function wrap(node: React.ReactNode, client: QueryClient) { - return renderToStaticMarkup( - - {node} - , - ); -} - -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(" — assistant.insightStatus gate", () => { - it("renders the card when the flag is on", () => { - const html = wrap( - , - 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( - , - 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( - , - makeClient({ - enabled: false, - coach: false, - briefing: false, - insightStatus: false, - documentAi: false, - }), - ); - expect(html).toBe(""); - }); -}); diff --git a/src/components/insights/__tests__/insight-status-card.test.tsx b/src/components/insights/__tests__/insight-status-card.test.tsx index b4f723c17..b4ee25e97 100644 --- a/src/components/insights/__tests__/insight-status-card.test.tsx +++ b/src/components/insights/__tests__/insight-status-card.test.tsx @@ -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:` token (the @@ -82,17 +93,6 @@ describe("", () => { expect(html).toContain("Pulse stayed inside the band."); }); - it("renders the no-provider setup state when no terminal text exists", () => { - const html = render( - , - ); - // 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( ", () => { 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( , ); const preparing = render( , ); - const noProvider = render( - , - ); const empty = render(); const populated = render( ", () => { 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"); diff --git a/src/components/insights/__tests__/layout-coach-fab.test.tsx b/src/components/insights/__tests__/layout-coach-fab.test.tsx index 5ad010ae3..5a6b43446 100644 --- a/src/components/insights/__tests__/layout-coach-fab.test.tsx +++ b/src/components/insights/__tests__/layout-coach-fab.test.tsx @@ -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, diff --git a/src/components/insights/ai-setup-hint.tsx b/src/components/insights/ai-setup-hint.tsx new file mode 100644 index 000000000..6e6ea316c --- /dev/null +++ b/src/components/insights/ai-setup-hint.tsx @@ -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 ( + + +

+ {t("insights.aiSetupHint.settingsBody")} +

+ + + +
+ ); + } + + return ( + + + + + +

+ {t("insights.aiSetupHint.body")} +

+ +
+
+ ); +} diff --git a/src/components/insights/ask-coach-action.tsx b/src/components/insights/ask-coach-action.tsx index c0e038126..b04783514 100644 --- a/src/components/insights/ask-coach-action.tsx +++ b/src/components/insights/ask-coach-action.tsx @@ -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 / : 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"); diff --git a/src/components/insights/coach-launch-button.tsx b/src/components/insights/coach-launch-button.tsx index 35433bf87..3b8802484 100644 --- a/src/components/insights/coach-launch-button.tsx +++ b/src/components/insights/coach-launch-button.tsx @@ -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 // `` for the matching FAB gate. - if (disableCoach) return null; + if (!coach.available) return null; const accessibleLabel = label ?? t("insights.heroActionAskCoach"); diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index 94498638a..114883526 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -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", diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx index 5651c244c..02057310a 100644 --- a/src/components/insights/coach-panel/chat-bubble.tsx +++ b/src/components/insights/coach-panel/chat-bubble.tsx @@ -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.` 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") && ( + {t("common.loading")} +

+ ); + } + if (isError || !data) { + return ( + void refetch()} + slot="coach-conversations-transcript-error" + /> + ); + } + return ( +
    + {data.messages.map((message) => ( +
  1. + + {message.role === "user" + ? t("insights.coach.transcriptYou") + : t("insights.coach.transcriptCoach")} + +

    + {message.content} +

    +
  2. + ))} +
+ ); +} diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts index a08d4a325..a46aff9ef 100644 --- a/src/components/insights/coach-panel/use-coach.ts +++ b/src/components/insights/coach-panel/use-coach.ts @@ -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 { diff --git a/src/components/insights/coincident-deviation-card.tsx b/src/components/insights/coincident-deviation-card.tsx index c909ca7fa..6d8926da7 100644 --- a/src/components/insights/coincident-deviation-card.tsx +++ b/src/components/insights/coincident-deviation-card.tsx @@ -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( + const { data, isError, refetch } = useDerivedMetric( 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 ( +
+ + void refetch()} + /> +
+ ); + } + // CLS-safe placeholder while the single read is in flight. if (!data) { return ; diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx index fc2d19a0c..536102b71 100644 --- a/src/components/insights/daily-briefing.tsx +++ b/src/components/insights/daily-briefing.tsx @@ -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({
)} - {(updatedAt || noProviderStale || generationFailed) && ( + {(updatedAt || generationFailed) && (
{updatedAt && (

)} - {/* 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 && ( -

- - {t("insights.dailyBriefing.staleNoProviderHint")} - - - {t("insights.dailyBriefing.noProviderAction")} - -

- )} {/* 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 && (

)}

- ) : noProvider ? ( - // v1.15.20 — no provider configured anywhere: a regenerate CTA - // would 422 forever, so point at Settings → AI instead. - } - title={t("insights.dailyBriefing.noProviderTitle")} - description={t("insights.dailyBriefing.noProviderDescription")} - action={ - - } - /> ) : ( ); + 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 ( +
+ void query.refetch()} + /> +
+ ); + } + if (!data) { - // Network/abort fallback — render the insufficient state honestly. + // Settled without a payload — render the insufficient state honestly. return ( } 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 diff --git a/src/components/insights/hero-strip.tsx b/src/components/insights/hero-strip.tsx index ef92e9184..eacaf9b5c 100644 --- a/src/components/insights/hero-strip.tsx +++ b/src/components/insights/hero-strip.tsx @@ -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 " 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({ )} - {/* 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 && ( - <> - - - {t("insights.dailyBriefing.staleNoProviderHint")} - - {t("insights.dailyBriefing.noProviderAction")} - - - - )} diff --git a/src/components/insights/insight-status-card.tsx b/src/components/insights/insight-status-card.tsx index df7e84c5b..a20e87f02 100644 --- a/src/components/insights/insight-status-card.tsx +++ b/src/components/insights/insight-status-card.tsx @@ -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 ( - - - - - -

- {t("insights.noProviderConfigured")} -

- - {t("insights.noProviderAction")} - -
-
- ); - } - if (!displayText) { return ( diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index dbf4d0ea6..9baeb1c2e 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -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 diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index 0714a96ab..6017824e3 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -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" }; diff --git a/src/components/insights/layout-coach-fab.tsx b/src/components/insights/layout-coach-fab.tsx index 19bc4f3c4..df0f850de 100644 --- a/src/components/insights/layout-coach-fab.tsx +++ b/src/components/insights/layout-coach-fab.tsx @@ -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(); diff --git a/src/components/insights/layout-coach-mount.tsx b/src/components/insights/layout-coach-mount.tsx index 84d6ff8f0..a997c1b44 100644 --- a/src/components/insights/layout-coach-mount.tsx +++ b/src/components/insights/layout-coach-mount.tsx @@ -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 ( { - // 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("/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 ? ( -

- {t("insights.pattern.loadError")} -

+ void refetch()} + /> ) : null} {!isLoading && !isError && data && !hasDiscovered ? (

{ + 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 ( +

+ this.setState({ failed: false })} + /> +
+ ); + } +} + +export function OverviewSectionBoundary({ + sectionId, + children, +}: { + sectionId: string; + children: ReactNode; +}) { + const { t } = useTranslations(); + return ( + + {children} + + ); +} diff --git a/src/components/insights/rhythm-events-card.tsx b/src/components/insights/rhythm-events-card.tsx index d9162d8c0..2367a516a 100644 --- a/src/components/insights/rhythm-events-card.tsx +++ b/src/components/insights/rhythm-events-card.tsx @@ -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 ( +
+ + void refetch()} + /> +
+ ); + } + // Data-availability gate — never paint an empty / alarming card. if (isLoading || !data || !data.hasEvents) return null; diff --git a/src/components/insights/use-insights-advisor.ts b/src/components/insights/use-insights-advisor.ts index 0823c247f..31eef1153 100644 --- a/src/components/insights/use-insights-advisor.ts +++ b/src/components/insights/use-insights-advisor.ts @@ -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, diff --git a/src/components/insights/workout-detail/day-context.tsx b/src/components/insights/workout-detail/day-context.tsx index 413a9dfa7..5cc99ce15 100644 --- a/src/components/insights/workout-detail/day-context.tsx +++ b/src/components/insights/workout-detail/day-context.tsx @@ -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 ? ( - - ) : null} + diff --git a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx index a58b764ac..9a969fcc0 100644 --- a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx +++ b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx @@ -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"; diff --git a/src/components/labs/__tests__/ocr-capability-gate.test.ts b/src/components/labs/__tests__/ocr-capability-gate.test.ts index 6d94d478f..2bf8e62e1 100644 --- a/src/components/labs/__tests__/ocr-capability-gate.test.ts +++ b/src/components/labs/__tests__/ocr-capability-gate.test.ts @@ -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[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); + } + }); }); diff --git a/src/components/labs/__tests__/ocr-review-dialog.test.tsx b/src/components/labs/__tests__/ocr-review-dialog.test.tsx index 29a3f5337..1ef88b1c9 100644 --- a/src/components/labs/__tests__/ocr-review-dialog.test.tsx +++ b/src/components/labs/__tests__/ocr-review-dialog.test.tsx @@ -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( @@ -59,6 +77,7 @@ function render( onOpenChange={() => {}} mode={options.mode ?? "vision"} pdfSupported={options.pdfSupported ?? true} + consentRequired={options.consentRequired} onCommitted={() => {}} /> , @@ -138,3 +157,47 @@ describe(" file picker", () => { expect(input.value).toBe(""); }); }); + +describe(" 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(/]*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(/]*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", + ); + }); +}); diff --git a/src/components/labs/ocr-review-dialog.tsx b/src/components/labs/ocr-review-dialog.tsx index e64c33577..b17162702 100644 --- a/src/components/labs/ocr-review-dialog.tsx +++ b/src/components/labs/ocr-review-dialog.tsx @@ -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 ? ( + + ) : stage === "pick" ? (
>( () => 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 diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index 4d8dfc467..993dda13e 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -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) diff --git a/src/components/medications/scheduling/__tests__/natural-language-extractor-errors.test.ts b/src/components/medications/scheduling/__tests__/natural-language-extractor-errors.test.ts new file mode 100644 index 000000000..f3d8c2530 --- /dev/null +++ b/src/components/medications/scheduling/__tests__/natural-language-extractor-errors.test.ts @@ -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"); + }); +}); diff --git a/src/components/medications/scheduling/natural-language-extractor.tsx b/src/components/medications/scheduling/natural-language-extractor.tsx index 8aad94571..0824067a5 100644 --- a/src/components/medications/scheduling/natural-language-extractor.tsx +++ b/src/components/medications/scheduling/natural-language-extractor.tsx @@ -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({ - )} - - {sharedKeyNote ? ( -

- {justGranted - ? t("onboarding.ai.offer.granted") - : t("onboarding.ai.sharedKeyNote")} -

- ) : null} - - {offerShared ? ( -
-

- {t("onboarding.ai.offer.title")} -

-

- {t("onboarding.ai.offer.body")} -

+ {sampleOpen ? ( + + ) : ( -
- ) : null} + )} - {sharedUnavailable ? ( -
+ {sharedKeyNote ? ( +

+ {justGranted + ? t("onboarding.ai.offer.granted") + : t("onboarding.ai.sharedKeyNote")} +

+ ) : null} + + {offerShared ? ( +
+

+ {t("onboarding.ai.offer.title")} +

+

+ {t("onboarding.ai.offer.body")} +

+ +
+ ) : null} + + {sharedUnavailable ? ( +
+

+ {t("onboarding.ai.unavailable.title")} +

+

+ {t("onboarding.ai.unavailable.body")} +

+
+ ) : null} + +

- {t("onboarding.ai.unavailable.title")} + {t("onboarding.ai.ladderTitle")}

-

- {t("onboarding.ai.unavailable.body")} -

-
- ) : null} - -
-

- {t("onboarding.ai.ladderTitle")} -

-
    - {/* Local first — the calm, private default — then BYOK, then +
      + {/* 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. */} -
    • -

      - {t("onboarding.ai.ladderLocalTitle")} -

      -

      - {t("onboarding.ai.ladderLocalBody")} -

      -
    • -
    • -

      - {t("onboarding.ai.ladderByokTitle")} -

      -

      - {t("onboarding.ai.ladderByokBody")} -

      -
    • -
    • -

      - {t("onboarding.ai.ladderOauthTitle")} -

      -

      - {t("onboarding.ai.ladderOauthBody")} -

      -
    • -
    -
+
  • +

    + {t("onboarding.ai.ladderLocalTitle")} +

    +

    + {t("onboarding.ai.ladderLocalBody")} +

    +
  • +
  • +

    + {t("onboarding.ai.ladderByokTitle")} +

    +

    + {t("onboarding.ai.ladderByokBody")} +

    +
  • +
  • +

    + {t("onboarding.ai.ladderOauthTitle")} +

    +

    + {t("onboarding.ai.ladderOauthBody")} +

    +
  • + +
    -

    - {t("onboarding.ai.keylessLine")} -

    +

    + {t("onboarding.ai.keylessLine")} +

    - + - - {t("onboarding.ai.setupCta")} - - + + {t("onboarding.ai.setupCta")} + + + ) : null}
    + +
    + ) : ( + + )} + {revoke.isError ? ( +

    + {t("settings.ai.consent.error")} +

    + ) : null} +
    + ); +} diff --git a/src/components/settings/ai/auto-read-card.tsx b/src/components/settings/ai/auto-read-card.tsx index 197598a9d..76b862265 100644 --- a/src/components/settings/ai/auto-read-card.tsx +++ b/src/components/settings/ai/auto-read-card.tsx @@ -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() { + {operatorOff ? ( +

    + {t("settings.ai.operatorOff.autoRead")} +

    + ) : null} + {pendingEnable ? (
    (); + 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 ( + + +

    + {allOff + ? t("settings.ai.operatorOff.allBody") + : t("settings.ai.operatorOff.someBody", { + switches: names.join(", "), + })} +

    +
    + ); +} diff --git a/src/components/settings/ai/runtime-actions-row.tsx b/src/components/settings/ai/runtime-actions-row.tsx index ddc043a4f..c0a1ebcce 100644 --- a/src/components/settings/ai/runtime-actions-row.tsx +++ b/src/components/settings/ai/runtime-actions-row.tsx @@ -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(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.
    - {canRegenerate && ( + {showRegenerate && (
    @@ -264,7 +276,7 @@ export function RuntimeActionsRow({ )} {t("settings.ai.testProvider")} - {canRegenerate && ( + {showRegenerate && ( + +
    + {openId === c.id ? : null} + + ))} + + )} + {history.hasNextPage ? ( + + ) : null} + + ); +} diff --git a/src/components/settings/coach-memory-section.tsx b/src/components/settings/coach-memory-section.tsx index 73826576a..67a4141bf 100644 --- a/src/components/settings/coach-memory-section.tsx +++ b/src/components/settings/coach-memory-section.tsx @@ -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 `` 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 { 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 ( ); } + +/** + * 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 ( + <> + + + + ); +} diff --git a/src/components/settings/coach-section.tsx b/src/components/settings/coach-section.tsx index fca4b52fb..947ef6650 100644 --- a/src/components/settings/coach-section.tsx +++ b/src/components/settings/coach-section.tsx @@ -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() {
    )} - {coachEnabled && } + + + {coachEnabled && }
    diff --git a/src/components/settings/insights-overview-arrange-section.tsx b/src/components/settings/insights-overview-arrange-section.tsx index 1d7a41206..5acb424cb 100644 --- a/src/components/settings/insights-overview-arrange-section.tsx +++ b/src/components/settings/insights-overview-arrange-section.tsx @@ -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(); - 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:`); 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 (
    = { 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 diff --git a/src/hooks/__tests__/use-feature-flags.test.tsx b/src/hooks/__tests__/use-feature-flags.test.tsx deleted file mode 100644 index 150e1a8ed..000000000 --- a/src/hooks/__tests__/use-feature-flags.test.tsx +++ /dev/null @@ -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
    {JSON.stringify(flags)}
    ; -} - -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(); - 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( - - - , - ); - 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( - - - , - ); - expect(html).toContain(""coach":false"); - expect(html).toContain(""insightStatus":false"); - }); -}); diff --git a/src/hooks/use-ai-capability.ts b/src/hooks/use-ai-capability.ts index b4c13d094..fa8721c30 100644 --- a/src/hooks/use-ai-capability.ts +++ b/src/hooks/use-ai-capability.ts @@ -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 +> | null { + const hasClient = useQueryClientMounted(); + if (!hasClient) return null; + // eslint-disable-next-line react-hooks/rules-of-hooks + return useAiCapabilityMapInner(); +} + +function useAiCapabilityMapInner(): Partial< + Record +> | null { + const { user } = useAuth(); + return user?.ai?.capabilities ?? null; +} diff --git a/src/hooks/use-disable-coach.ts b/src/hooks/use-disable-coach.ts deleted file mode 100644 index fa1eaca0e..000000000 --- a/src/hooks/use-disable-coach.ts +++ /dev/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 `` (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 (``, ``, - * `` …) render the component in isolation and don't. - * - * v1.4.48 M4 — share the ``-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; -} diff --git a/src/hooks/use-feature-flags.ts b/src/hooks/use-feature-flags.ts deleted file mode 100644 index 33880d0d4..000000000 --- a/src/hooks/use-feature-flags.ts +++ /dev/null @@ -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/` 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 { - // `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 - * `` whose unit tests render without a - * ``. 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; -} diff --git a/src/hooks/use-grant-document-reading-consent.ts b/src/hooks/use-grant-document-reading-consent.ts new file mode 100644 index 000000000..b43f673b9 --- /dev/null +++ b/src/hooks/use-grant-document-reading-consent.ts @@ -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"), + }); + }, + }); +} diff --git a/src/hooks/use-insight-status.ts b/src/hooks/use-insight-status.ts index b43141262..ebccbecc3 100644 --- a/src/hooks/use-insight-status.ts +++ b/src/hooks/use-insight-status.ts @@ -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) => diff --git a/src/hooks/use-module-enabled.ts b/src/hooks/use-module-enabled.ts index fd4d6bf3e..5d405125a 100644 --- a/src/hooks/use-module-enabled.ts +++ b/src/hooks/use-module-enabled.ts @@ -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 (``, the * isolated score-card snapshots, …) renders without a * ``. We gate the inner hook on diff --git a/src/hooks/use-nav-modules.ts b/src/hooks/use-nav-modules.ts new file mode 100644 index 000000000..105fff65b --- /dev/null +++ b/src/hooks/use-nav-modules.ts @@ -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], + ); +} diff --git a/src/hooks/use-workouts.ts b/src/hooks/use-workouts.ts index 1a8401ea1..f37d0ec33 100644 --- a/src/hooks/use-workouts.ts +++ b/src/hooks/use-workouts.ts @@ -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, }); diff --git a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx b/src/lib/feature-flags/__tests__/coach-cascade.test.tsx deleted file mode 100644 index 0b5adcbfb..000000000 --- a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx +++ /dev/null @@ -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 `` 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): 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( - - {node} - , - ); -} - -// ──────────────────────────────────────────────────────────────────── -// 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 ; wrap so - // the Coach-flag-off branch is the dispositive guard. - - - - ), - proofWhenOn: 'data-slot="coach-launch-inline"', - }, - { - name: "LayoutCoachFab sticky mobile button", - mount: () => ( - - - - ), - // 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: () => ( - - - - ), - // 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 = [ - // 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); - }); -}); diff --git a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx deleted file mode 100644 index 360ded7c4..000000000 --- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx +++ /dev/null @@ -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( - "@/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 `` 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( - - {node} - , - ); -} - -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: () => ( - - - - ), - // 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: () => ( - - - - ), - // 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: () => ( - - - - ), - 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 = [ - // 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([]); - }); -}); diff --git a/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts b/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts index 1a6cdd615..b80adf677 100644 --- a/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts +++ b/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts @@ -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", diff --git a/src/lib/onboarding/__tests__/checklist.test.ts b/src/lib/onboarding/__tests__/checklist.test.ts index 06d5cf2bc..ab84a7908 100644 --- a/src/lib/onboarding/__tests__/checklist.test.ts +++ b/src/lib/onboarding/__tests__/checklist.test.ts @@ -56,6 +56,7 @@ function inputs( dataSourceConnected: false, notificationsConfigured: false, insightsConfigured: false, + aiConfigurable: true, dismissedIds: new Set(), 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, diff --git a/src/lib/onboarding/checklist.ts b/src/lib/onboarding/checklist.ts index 20fd24d3c..e0690f7e5 100644 --- a/src/lib/onboarding/checklist.ts +++ b/src/lib/onboarding/checklist.ts @@ -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; /** @@ -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 diff --git a/src/lib/query-keys/settings.ts b/src/lib/query-keys/settings.ts index edf9fadbf..43b8effa1 100644 --- a/src/lib/query-keys/settings.ts +++ b/src/lib/query-keys/settings.ts @@ -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, };