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

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

No files matched your search

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