From 03ecb5bd52545c2b1c8b6f943fe64d3aae9fd920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 12:05:52 +0200 Subject: [PATCH 01/11] fix(coach): keep stored plans and conversations reachable with the Coach off The page gate matched /coach/plans and /coach/conversations to the Coach module by the nav prefix, so with the Coach switched off by the operator or hidden by the person both pages were replaced by the module notice and never reached their read-only mode. Both are now stored-record pages the gate leaves alone. With the Coach off nothing in the navigation led there either. Settings now carries a plans card, shown when plans exist, and the conversations card links to the full list. --- messages/de.json | 8 ++- messages/en.json | 8 ++- messages/es.json | 8 ++- messages/fr.json | 8 ++- messages/it.json | 8 ++- messages/ko.json | 8 ++- messages/pl.json | 8 ++- .../__tests__/module-page-gate.test.tsx | 42 ++++++++++-- src/components/layout/module-page-gate.tsx | 20 +++++- .../__tests__/coach-memory-while-off.test.tsx | 64 +++++++++++++++++-- .../coach-conversations-memory-card.tsx | 47 ++++++++++---- .../settings/coach-memory-section.tsx | 2 + .../settings/coach-plans-memory-card.tsx | 62 ++++++++++++++++++ src/components/settings/coach-section.tsx | 3 + 14 files changed, 265 insertions(+), 31 deletions(-) create mode 100644 src/components/settings/coach-plans-memory-card.tsx diff --git a/messages/de.json b/messages/de.json index 68029e2d1..5525ae9b3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5867,7 +5867,13 @@ "coachConversations": { "title": "Coach-Gespräche", "description": "Öffne ein Gespräch, um es zu lesen, oder lösche es.", - "more": "Mehr anzeigen" + "more": "Mehr anzeigen", + "openAll": "Alle Gespräche öffnen" + }, + "coachPlans": { + "title": "Coach-Pläne", + "description": "Die Pläne, die du mit dem Coach vereinbart hast, zum Lesen oder Entfernen.", + "open": "Pläne öffnen" } }, "dashboard": { diff --git a/messages/en.json b/messages/en.json index e39c7e57d..c5d1af4df 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5867,7 +5867,13 @@ "coachConversations": { "title": "Coach conversations", "description": "Open a conversation to read it, or delete it.", - "more": "Show more" + "more": "Show more", + "openAll": "Open all conversations" + }, + "coachPlans": { + "title": "Coach plans", + "description": "The plans you agreed with the Coach, to read or remove.", + "open": "Open plans" } }, "dashboard": { diff --git a/messages/es.json b/messages/es.json index 6e86d01ab..b4b3587f2 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5867,7 +5867,13 @@ "coachConversations": { "title": "Conversaciones con el Coach", "description": "Abre una conversación para leerla o elimínala.", - "more": "Mostrar más" + "more": "Mostrar más", + "openAll": "Abrir todas las conversaciones" + }, + "coachPlans": { + "title": "Planes del Coach", + "description": "Los planes que acordaste con el Coach, para leerlos o eliminarlos.", + "open": "Abrir planes" } }, "dashboard": { diff --git a/messages/fr.json b/messages/fr.json index 9fe90dcb7..98bedae5c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5867,7 +5867,13 @@ "coachConversations": { "title": "Conversations avec le Coach", "description": "Ouvrez une conversation pour la lire, ou supprimez-la.", - "more": "Afficher plus" + "more": "Afficher plus", + "openAll": "Ouvrir toutes les conversations" + }, + "coachPlans": { + "title": "Plans du Coach", + "description": "Les plans convenus avec le Coach, à lire ou à supprimer.", + "open": "Ouvrir les plans" } }, "dashboard": { diff --git a/messages/it.json b/messages/it.json index f617caf03..f9aa7f83b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5867,7 +5867,13 @@ "coachConversations": { "title": "Conversazioni con il Coach", "description": "Apri una conversazione per leggerla oppure eliminala.", - "more": "Mostra altro" + "more": "Mostra altro", + "openAll": "Apri tutte le conversazioni" + }, + "coachPlans": { + "title": "Piani del Coach", + "description": "I piani concordati con il Coach, da leggere o rimuovere.", + "open": "Apri i piani" } }, "dashboard": { diff --git a/messages/ko.json b/messages/ko.json index b03990b57..7d71980ef 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5867,7 +5867,13 @@ "coachConversations": { "title": "코치 대화", "description": "대화를 열어 읽거나 삭제하세요.", - "more": "더 보기" + "more": "더 보기", + "openAll": "모든 대화 열기" + }, + "coachPlans": { + "title": "코치 계획", + "description": "코치와 합의한 계획을 읽거나 삭제할 수 있습니다.", + "open": "계획 열기" } }, "dashboard": { diff --git a/messages/pl.json b/messages/pl.json index d041381ff..f2160a932 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5867,7 +5867,13 @@ "coachConversations": { "title": "Rozmowy z Coachem", "description": "Otwórz rozmowę, aby ją przeczytać, albo ją usuń.", - "more": "Pokaż więcej" + "more": "Pokaż więcej", + "openAll": "Otwórz wszystkie rozmowy" + }, + "coachPlans": { + "title": "Plany z Coachem", + "description": "Plany uzgodnione z Coachem, do przeczytania lub usunięcia.", + "open": "Otwórz plany" } }, "dashboard": { diff --git a/src/components/layout/__tests__/module-page-gate.test.tsx b/src/components/layout/__tests__/module-page-gate.test.tsx index 37828815d..f343dd21e 100644 --- a/src/components/layout/__tests__/module-page-gate.test.tsx +++ b/src/components/layout/__tests__/module-page-gate.test.tsx @@ -51,12 +51,17 @@ describe("moduleOwningPath", () => { expect(moduleOwningPath(path)).toBe(owner); }); - it.each(["/", "/measurements", "/insights", "/insights/weight", "/checkups"])( - "%s belongs to no module", - (path) => { - expect(moduleOwningPath(path)).toBeUndefined(); - }, - ); + it.each([ + "/", + "/measurements", + "/insights", + "/insights/weight", + "/checkups", + "/coach/plans", + "/coach/conversations", + ])("%s belongs to no module", (path) => { + expect(moduleOwningPath(path)).toBeUndefined(); + }); }); describe("", () => { @@ -98,4 +103,29 @@ describe("", () => { }); expect(html).toContain('data-module="medications"'); }); + + // Stored plans and conversations are the person's own records: their pages + // turn read-only with the Coach unavailable instead of being replaced. + describe.each(["/coach/plans", "/coach/conversations"])("%s", (path) => { + it.each([ + ["operator switched the Coach off", "unavailable"], + ["the person hid the Coach", "disabled"], + ])("stays reachable when %s", (_label, access) => { + const html = render(path, { + modules: { coach: false }, + moduleAccess: { coach: access }, + }); + expect(html).toContain('data-testid="page"'); + expect(html).not.toContain("module-disabled-notice"); + }); + }); + + it("still replaces the Coach page itself when the Coach is off", () => { + const html = render("/coach", { + modules: { coach: false }, + moduleAccess: { coach: "unavailable" }, + }); + expect(html).not.toContain('data-testid="page"'); + expect(html).toContain('data-module="coach"'); + }); }); diff --git a/src/components/layout/module-page-gate.tsx b/src/components/layout/module-page-gate.tsx index cde724223..8cd75fc4f 100644 --- a/src/components/layout/module-page-gate.tsx +++ b/src/components/layout/module-page-gate.tsx @@ -18,15 +18,33 @@ import { surfaceModule } from "@/lib/modules/surface"; const INSIGHTS_PREFIX = "/insights/"; +/** + * Pages that hold the person's own stored records under a module's nav entry. + * They stay reachable with the module off and turn read-only on their own, so + * a record can always be read and erased; the gate does not replace them. + */ +const STORED_RECORD_PAGES: ReadonlyArray = [ + "/coach/plans", + "/coach/conversations", +]; + +function isStoredRecordPage(pathname: string): boolean { + return STORED_RECORD_PAGES.some( + (p) => pathname === p || pathname.startsWith(`${p}/`), + ); +} + /** * The module that owns the page at `pathname`, from the one surface map. * * An Insights sub-page answers by its slug (`insights-page:`), so * `/insights/mood` and `/insights/workouts/` follow their own module while * `/insights` itself follows none. Every other page answers through the nav - * destination it sits under (`nav:`), most specific first. + * destination it sits under (`nav:`), most specific first. A stored-record + * page (`/coach/plans`, `/coach/conversations`) follows none either. */ export function moduleOwningPath(pathname: string): ModuleKey | undefined { + if (isStoredRecordPage(pathname)) return undefined; if (pathname.startsWith(INSIGHTS_PREFIX)) { const slug = pathname.slice(INSIGHTS_PREFIX.length).split("/")[0]; const owner = surfaceModule(`insights-page:${slug}`); diff --git a/src/components/settings/__tests__/coach-memory-while-off.test.tsx b/src/components/settings/__tests__/coach-memory-while-off.test.tsx index 0459157c3..96eecaca1 100644 --- a/src/components/settings/__tests__/coach-memory-while-off.test.tsx +++ b/src/components/settings/__tests__/coach-memory-while-off.test.tsx @@ -62,7 +62,22 @@ const CONVERSATION = { attachments: [], }; -function client(stored: { facts: unknown[]; conversations: unknown[] }) { +type Stored = { facts: unknown[]; conversations: unknown[]; plans?: unknown[] }; + +const PLAN = { + id: "plan-1", + metric: "steps", + ifCue: "after lunch", + thenAction: "walk ten minutes", + target: null, + status: "active", + reviewDate: null, + sourceConversationId: null, + createdAt: "2026-09-01T08:00:00.000Z", + updatedAt: "2026-09-01T08:00:00.000Z", +}; + +function client(stored: Stored) { const c = new QueryClient({ defaultOptions: { queries: { retry: 0, staleTime: Infinity } }, }); @@ -71,13 +86,12 @@ function client(stored: { facts: unknown[]; conversations: unknown[] }) { pages: [{ conversations: stored.conversations, nextCursor: null }], pageParams: [null], }); + if (stored.plans) + c.setQueryData(queryKeys.coachPlans("scope:all"), stored.plans); return c; } -function render( - node: React.ReactNode, - stored: { facts: unknown[]; conversations: unknown[] }, -) { +function render(node: React.ReactNode, stored: Stored) { return renderToStaticMarkup( {node} @@ -142,3 +156,43 @@ describe(" (Settings → AI while the Coach page is out of re expect(html).toBe(""); }); }); + +describe("the way to /coach/plans and /coach/conversations with the Coach off", () => { + it("links to the stored plans from Settings → AI when plans exist", () => { + const html = render(, { + facts: [], + conversations: [], + plans: [PLAN], + }); + expect(html).toContain('data-testid="settings-coach-plans-card"'); + expect(html).toContain('href="/coach/plans"'); + }); + + it("links to the stored plans from Settings → Coach with Hide Coach on", () => { + authState.disableCoach = true; + const html = render(, { + facts: [], + conversations: [], + plans: [PLAN], + }); + expect(html).toContain('href="/coach/plans"'); + }); + + it("shows no plans card without plans", () => { + const html = render(, { + facts: [], + conversations: [], + plans: [], + }); + expect(html).not.toContain("settings-coach-plans-card"); + }); + + it("links to the full conversation list from the conversations card", () => { + const html = render(, { + facts: [], + conversations: [CONVERSATION], + }); + expect(html).toContain('data-slot="settings-coach-conversations-open-all"'); + expect(html).toContain('href="/coach/conversations"'); + }); +}); diff --git a/src/components/settings/coach-conversations-memory-card.tsx b/src/components/settings/coach-conversations-memory-card.tsx index e218a3725..a2b2ad284 100644 --- a/src/components/settings/coach-conversations-memory-card.tsx +++ b/src/components/settings/coach-conversations-memory-card.tsx @@ -1,6 +1,7 @@ "use client"; import { useMemo, useState } from "react"; +import Link from "next/link"; import { MessagesSquare, Trash2 } from "lucide-react"; import { toast } from "sonner"; @@ -8,6 +9,7 @@ import { Button } from "@/components/ui/button"; import { QueryErrorRow } from "@/components/ui/query-error-row"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; import { ConversationTranscript } from "@/components/insights/coach-panel/conversation-transcript"; import { useCoachConversationHistory, @@ -26,6 +28,10 @@ import { formatRelativeTime } from "@/lib/i18n/relative-time"; * conversation list uses). Reading and deleting never ask the Coach; the * routes behind them are data routes. * + * The card links to `/coach/conversations`, which stays reachable and + * read-only with the Coach off, so the full list and its search are one step + * away even when no navigation entry leads there. + * * `hideWhenEmpty`: the card renders nothing when there is nothing stored. * Settings → AI mounts it that way while the Coach is off, so it appears only * when there is something to see. @@ -121,18 +127,35 @@ export function CoachConversationsMemoryCard({ ))} )} - {history.hasNextPage ? ( - - ) : null} + + {history.hasNextPage ? ( + + ) : null} + {visible.length > 0 ? ( + + ) : null} + ); } diff --git a/src/components/settings/coach-memory-section.tsx b/src/components/settings/coach-memory-section.tsx index 7625999a6..79b61e663 100644 --- a/src/components/settings/coach-memory-section.tsx +++ b/src/components/settings/coach-memory-section.tsx @@ -53,6 +53,7 @@ import { toast } from "sonner"; import { apiDelete, apiGet } from "@/lib/api/api-fetch"; import { CoachConversationsMemoryCard } from "@/components/settings/coach-conversations-memory-card"; import { CoachRemindersSection } from "@/components/settings/coach-reminders-section"; +import { CoachPlansMemoryCard } from "@/components/settings/coach-plans-memory-card"; /** Closed enum mirrored from the server `CoachFact.category` column. */ const FACT_CATEGORIES = [ @@ -312,6 +313,7 @@ export function StoredCoachMemory({ isAuthenticated={isAuthenticated} hideWhenEmpty /> + ); diff --git a/src/components/settings/coach-plans-memory-card.tsx b/src/components/settings/coach-plans-memory-card.tsx new file mode 100644 index 000000000..6567bcfe1 --- /dev/null +++ b/src/components/settings/coach-plans-memory-card.tsx @@ -0,0 +1,62 @@ +"use client"; + +import Link from "next/link"; +import { Target } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { useCoachPlans } from "@/hooks/use-coach-plans"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * The way to the Coach's stored plans from Settings. + * + * Plans are the person's own record: `/coach/plans` stays reachable and turns + * read-only while the Coach is unavailable, but with the Coach off nothing in + * the navigation leads there any more. This card is that way in. It renders + * only when plans exist, so a person who never agreed a plan sees nothing. + * Reads the same `?scope=all` slot the plans page reads, so the count and the + * page cannot disagree. + */ +export function CoachPlansMemoryCard({ + isAuthenticated, +}: { + isAuthenticated: boolean; +}) { + const { t } = useTranslations(); + const query = useCoachPlans({ + filter: { scope: "all" }, + enabled: isAuthenticated, + }); + const count = query.data?.length ?? 0; + if (count === 0) return null; + + return ( + + + + + + + ); +} diff --git a/src/components/settings/coach-section.tsx b/src/components/settings/coach-section.tsx index 882c55ebd..e4a0ccc83 100644 --- a/src/components/settings/coach-section.tsx +++ b/src/components/settings/coach-section.tsx @@ -34,6 +34,7 @@ import { DisableCoachCard } from "@/components/settings/ai/disable-coach-card"; import { CoachMemorySection } from "@/components/settings/coach-memory-section"; import { CoachConversationsMemoryCard } from "@/components/settings/coach-conversations-memory-card"; import { CoachRemindersSection } from "@/components/settings/coach-reminders-section"; +import { CoachPlansMemoryCard } from "@/components/settings/coach-plans-memory-card"; import { CoachNudgeCard } from "@/components/settings/coach-nudge-card"; import { CoachPrefsSection } from "@/components/settings/coach-prefs-section"; import { useAuth } from "@/hooks/use-auth"; @@ -70,6 +71,8 @@ export function CoachSection() { + + Date: Thu, 24 Sep 2026 12:11:14 +0200 Subject: [PATCH 02/11] fix(ai): refresh the account's AI answer after every change to its inputs Every AI surface reads its capability from the ai block on /api/auth/me, but setting up or removing a provider, signing in to or out of Codex, saving the fallback chain, the central-Codex opt-in, the local-OCR switch, a minted consent receipt, the admin's server key and central Codex, and a guardian's save inside a managed record evicted only their own reads. The surfaces stayed on the old answer until a reload. All of these writes, and the ones that already refreshed the payload (consent, modules, Hide Coach, the operator's switches), now evict one bundle, aiInputDependentKeys. A structural test holds every client write to those endpoints to it. --- .../ai-input-invalidation-guard.test.ts | 120 ++++++++++++++++++ .../admin/ai-server-key-section.tsx | 8 +- src/components/admin/assistant-section.tsx | 8 +- .../admin/central-codex-section.tsx | 8 +- .../admin/module-availability-section.tsx | 8 +- src/components/labs/use-ocr-extract.ts | 8 +- src/components/onboarding/done-screen.tsx | 10 +- .../settings/ai/ai-consent-card.tsx | 18 ++- .../settings/ai/ai-insights-card.tsx | 24 +++- .../settings/ai/anthropic-provider-form.tsx | 5 +- .../settings/ai/central-codex-switch.tsx | 10 +- .../settings/ai/codex-provider-form.tsx | 8 +- .../settings/ai/compat-provider-form.tsx | 5 +- .../settings/ai/disable-coach-card.tsx | 6 +- .../settings/ai/fallback-chain-card.tsx | 4 +- .../settings/ai/local-provider-form.tsx | 5 +- .../settings/ai/openai-provider-form.tsx | 5 +- .../settings/ai/response-timeout-card.tsx | 4 +- .../managed-record-settings-section.tsx | 10 +- src/components/settings/mcp-section.tsx | 8 +- src/components/settings/modules-section.tsx | 9 +- .../use-grant-document-reading-consent.ts | 8 +- src/lib/query-keys/index.ts | 23 ++++ 23 files changed, 260 insertions(+), 62 deletions(-) create mode 100644 src/__tests__/ai-input-invalidation-guard.test.ts diff --git a/src/__tests__/ai-input-invalidation-guard.test.ts b/src/__tests__/ai-input-invalidation-guard.test.ts new file mode 100644 index 000000000..6b890ceed --- /dev/null +++ b/src/__tests__/ai-input-invalidation-guard.test.ts @@ -0,0 +1,120 @@ +/** + * Structural guard: every client write that changes an input of the `ai` + * block on `GET /api/auth/me` evicts it through `aiInputDependentKeys`. + * + * Every AI surface on the web reads its capability from that block. Before + * v1.39 shipped, the provider forms, the Codex sign-in, the fallback chain and + * the central-Codex switch evicted only the provider and insights reads, so a + * freshly configured provider left every AI surface on "not set up" until a + * reload. The fix routes all of them through one bundle; this guard keeps a + * new write site from re-deciding the keys by hand. + * + * The matcher finds write calls (`apiPost/apiPut/apiPatch/apiDelete`, and + * `apiFetch`/`apiFetchRaw` with a write method) whose literal path names one + * of the endpoints below, and asserts the file calls + * `invalidateKeys(, aiInputDependentKeys)`. A tripwire, not a proof: + * it holds the file, not each mutation in it (one mutation can branch over + * two endpoints and evict once), and a path built at runtime would slip the + * matcher, which is why the matched total carries a floor and an empty sweep + * fails. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { aiInputDependentKeys, queryKeys } from "@/lib/query-keys"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +/** + * The endpoints whose writes change an input of the capability answer. + * Starting a device sign-in changes nothing until the poll reports success, + * so the `device-start` legs are not listed. + */ +const AI_INPUT_ENDPOINTS = [ + "/api/user/ai-provider", + "/api/insights/provider-chain", + "/api/auth/codex/device-poll", + "/api/auth/codex/disconnect", + "/api/auth/me/use-central-codex", + "/api/auth/me/labs-local-ocr", + "/api/consent/ai", + "/api/auth/me/modules", + "/api/auth/me/disable-coach", + "/api/admin/ai-settings", + "/api/admin/settings/assistant-flags", + "/api/admin/settings/module-availability", + "/api/admin/central-codex/device-poll", + "/api/admin/central-codex", + // A managed record's modules / coach / profile families, written by the + // guardian from inside that record. + "/api/record-settings", +] as const; + +const HELPER_WRITE = + /\bapi(?:Post|Put|Patch|Delete)\s*(?:<[^()]*?>)?\s*\(\s*["'`](\/api\/[^"'`?]+)/g; +const RAW_WRITE = + /\bapiFetch(?:Raw)?\s*(?:<[^()]*?>)?\s*\(\s*["'`](\/api\/[^"'`?]+)[^"'`]*["'`]\s*,\s*\{[^}]*?\bmethod:\s*["'](?:POST|PUT|PATCH|DELETE)["']/g; +const BUNDLE_CALL = + /\binvalidateKeys\(\s*[A-Za-z_]\w*\s*,\s*aiInputDependentKeys\b/g; + +function isAiInputEndpoint(path: string): boolean { + if (path.endsWith("/device-start")) return false; + return AI_INPUT_ENDPOINTS.some((e) => path === e || path.startsWith(`${e}/`)); +} + +function inScope(rel: string): boolean { + if (rel.includes("__tests__") || /\.test\.tsx?$/.test(rel)) return false; + if (rel.startsWith("app/api/") || rel.startsWith("generated/")) return false; + return ( + rel.startsWith("components/") || + rel.startsWith("hooks/") || + rel.startsWith("app/") + ); +} + +function writeSites(source: string): string[] { + const out: string[] = []; + for (const re of [HELPER_WRITE, RAW_WRITE]) { + for (const m of source.matchAll(re)) { + if (isAiInputEndpoint(m[1]!)) out.push(m[1]!); + } + } + return out; +} + +const files = walkSourceFiles(SRC, { floor: 3000 }).filter(inScope); +const sites = files + .map((rel) => { + const source = readFileSync(join(SRC, rel), "utf8"); + return { + rel, + writes: writeSites(source), + bundleCalls: [...source.matchAll(BUNDLE_CALL)].length, + }; + }) + .filter((s) => s.writes.length > 0); + +describe("AI-input writes evict the capability answer", () => { + it("the bundle carries the account payload every AI surface reads", () => { + expect(aiInputDependentKeys).toContainEqual(queryKeys.authMe()); + }); + + it("finds the write sites it is meant to hold (an empty sweep is a broken matcher)", () => { + const total = sites.reduce((n, s) => n + s.writes.length, 0); + expect(sites.length).toBeGreaterThanOrEqual(21); + expect(total).toBeGreaterThanOrEqual(22); + }); + + it.each(sites.map((s) => [s.rel, s] as const))( + "%s invalidates aiInputDependentKeys", + (_rel, site) => { + expect( + site.bundleCalls, + `writes to ${site.writes.join(", ")}`, + ).toBeGreaterThanOrEqual(1); + }, + ); +}); diff --git a/src/components/admin/ai-server-key-section.tsx b/src/components/admin/ai-server-key-section.tsx index d9f0e6f1f..71799e308 100644 --- a/src/components/admin/ai-server-key-section.tsx +++ b/src/components/admin/ai-server-key-section.tsx @@ -14,7 +14,11 @@ import { Label } from "@/components/ui/label"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { + aiInputDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; import { PasswordInput } from "./_shared"; @@ -74,6 +78,8 @@ export function AiServerKeySection() { queryClient.invalidateQueries({ queryKey: queryKeys.adminAiServerKey(), }); + // The server key is a provider input of the admin's own AI answer. + void invalidateKeys(queryClient, aiInputDependentKeys); toast.success(t("admin.aiServerKey.saved")); }, onError: (error: unknown) => { diff --git a/src/components/admin/assistant-section.tsx b/src/components/admin/assistant-section.tsx index 6181a04ed..11872bbb9 100644 --- a/src/components/admin/assistant-section.tsx +++ b/src/components/admin/assistant-section.tsx @@ -7,7 +7,11 @@ import { toast } from "sonner"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { + aiInputDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; import { SettingsToggle } from "./_shared"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; @@ -74,7 +78,7 @@ function useUpdateAssistantFlags() { // Every record's resolved AI capabilities ride the account payload, // 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() }); + void invalidateKeys(client, aiInputDependentKeys); toast.success(t("common.saved")); }, onError: (err) => { diff --git a/src/components/admin/central-codex-section.tsx b/src/components/admin/central-codex-section.tsx index 68b582074..f79daedf4 100644 --- a/src/components/admin/central-codex-section.tsx +++ b/src/components/admin/central-codex-section.tsx @@ -30,7 +30,11 @@ import { SettingsCardHeader } from "@/components/settings/_card-header"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; import { formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { + aiInputDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; interface CentralCodexStatus { status: string; @@ -100,6 +104,7 @@ export function CentralCodexSection() { queryClient.invalidateQueries({ queryKey: queryKeys.adminCentralCodex(), }); + void invalidateKeys(queryClient, aiInputDependentKeys); return; } if (!cancelled) setTimeout(tick, intervalMs); @@ -135,6 +140,7 @@ export function CentralCodexSection() { queryClient.invalidateQueries({ queryKey: queryKeys.adminCentralCodex(), }); + void invalidateKeys(queryClient, aiInputDependentKeys); } catch (err) { toast.error( err instanceof Error ? err.message : t("admin.centralCodex.error"), diff --git a/src/components/admin/module-availability-section.tsx b/src/components/admin/module-availability-section.tsx index 7e1f8513a..91648eb22 100644 --- a/src/components/admin/module-availability-section.tsx +++ b/src/components/admin/module-availability-section.tsx @@ -8,7 +8,11 @@ import { toast } from "sonner"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { + aiInputDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; import { MODULE_KEYS, MODULE_REGISTRY, @@ -66,7 +70,7 @@ function useUpdateModuleAvailability() { client.setQueryData(queryKeys.adminModuleAvailability(), data); // The resolved `/api/auth/me` module map depends on this operator // layer; bust it so the operator sees the change within the session. - client.invalidateQueries({ queryKey: queryKeys.authMe() }); + void invalidateKeys(client, aiInputDependentKeys); toast.success(t("common.saved")); }, onError: (err) => { diff --git a/src/components/labs/use-ocr-extract.ts b/src/components/labs/use-ocr-extract.ts index 5748cdac6..999e00f9a 100644 --- a/src/components/labs/use-ocr-extract.ts +++ b/src/components/labs/use-ocr-extract.ts @@ -16,7 +16,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiFetch, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; import { ocrImageToText } from "@/lib/labs/local-ocr"; -import { queryKeys } from "@/lib/query-keys"; +import { + aiInputDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; import type { OcrCapabilityDto, OcrCommitResponseDto, @@ -149,6 +153,8 @@ export function useUpdateLabsLocalOcr() { queryClient.setQueryData(queryKeys.labsLocalOcr(), data); // The toggle changes whether text-mode scanning is available. queryClient.invalidateQueries({ queryKey: queryKeys.ocrCapability() }); + // It is also a provider input of the `labsOcr` capability on `/me`. + void invalidateKeys(queryClient, aiInputDependentKeys); }, }); } diff --git a/src/components/onboarding/done-screen.tsx b/src/components/onboarding/done-screen.tsx index 488ce1165..02462c2ba 100644 --- a/src/components/onboarding/done-screen.tsx +++ b/src/components/onboarding/done-screen.tsx @@ -25,7 +25,11 @@ import { useOnboardingAnswer } from "@/components/onboarding/use-onboarding-flow import { useAuth } from "@/hooks/use-auth"; import { useAccountSwitch } from "@/hooks/use-account-switch"; import { setTourReferrer } from "@/components/onboarding/tour-launcher"; -import { queryKeys } from "@/lib/query-keys"; +import { + aiInputDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; import { markChecklistExpanded } from "@/lib/onboarding/checklist-storage"; @@ -162,9 +166,7 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) { }, onSuccess: async () => { setJustGranted(true); - await queryClient.invalidateQueries({ - queryKey: queryKeys.userAiProvider(), - }); + await invalidateKeys(queryClient, aiInputDependentKeys); await queryClient.invalidateQueries({ queryKey: queryKeys.aiConsentReceipt("ai_full"), }); diff --git a/src/components/settings/ai/ai-consent-card.tsx b/src/components/settings/ai/ai-consent-card.tsx index 7f3050ce1..0741df1cc 100644 --- a/src/components/settings/ai/ai-consent-card.tsx +++ b/src/components/settings/ai/ai-consent-card.tsx @@ -39,7 +39,11 @@ 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"; -import { queryKeys } from "@/lib/query-keys"; +import { + aiInputDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; type ConsentReceiptWire = { id: string; @@ -86,10 +90,7 @@ export function AiConsentCard({ await queryClient.invalidateQueries({ queryKey: queryKeys.aiConsentReceipt("ai_full"), }); - await queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); - await queryClient.invalidateQueries({ - queryKey: queryKeys.insightsProviderChain(), - }); + await invalidateKeys(queryClient, aiInputDependentKeys); }, }); @@ -110,10 +111,7 @@ export function AiConsentCard({ await queryClient.invalidateQueries({ queryKey: queryKeys.aiConsentReceipt("ai_full"), }); - await queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); - await queryClient.invalidateQueries({ - queryKey: queryKeys.insightsProviderChain(), - }); + await invalidateKeys(queryClient, aiInputDependentKeys); }, }); @@ -292,7 +290,7 @@ function DocumentReadingConsentRow({ await queryClient.invalidateQueries({ queryKey: queryKeys.aiConsentReceipt("ai_extraction"), }); - await queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); + await invalidateKeys(queryClient, aiInputDependentKeys); }, }); diff --git a/src/components/settings/ai/ai-insights-card.tsx b/src/components/settings/ai/ai-insights-card.tsx index 7fdc3ccf8..9daea5ea4 100644 --- a/src/components/settings/ai/ai-insights-card.tsx +++ b/src/components/settings/ai/ai-insights-card.tsx @@ -28,7 +28,11 @@ import { SettingsCardHeader } from "@/components/settings/_card-header"; import { apiFetchRaw } from "@/lib/api/api-fetch"; import { formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { + aiInputDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; import { AdminOpenAIProviderForm } from "./admin-openai-provider-form"; import { AiConsentCard } from "./ai-consent-card"; @@ -127,10 +131,20 @@ export function AiInsightsCard({ // settings UI. Runs once per mount of the AI-settings surface. useEffect(() => { if (!isAuthenticated) return; - void apiFetchRaw("/api/consent/ai/web", { method: "POST" }).catch(() => { - /* best-effort heal — the explicit grant path stays available */ - }); - }, [isAuthenticated]); + // A heal that minted a receipt changed an input of the account's AI + // capability answer, so `/me` re-resolves; a no-op heal leaves it alone. + void apiFetchRaw("/api/consent/ai/web", { method: "POST" }) + .then(async (res) => { + if (!res.ok) return; + const body = (await res.json()) as { data?: { minted?: boolean } }; + if (body.data?.minted) { + void invalidateKeys(queryClient, aiInputDependentKeys); + } + }) + .catch(() => { + /* best-effort heal — the explicit grant path stays available */ + }); + }, [isAuthenticated, queryClient]); // The Select is URL-driven so the SSR test can pick the branch and // a deep link works. Default = `?provider=…` query param when the diff --git a/src/components/settings/ai/anthropic-provider-form.tsx b/src/components/settings/ai/anthropic-provider-form.tsx index acd79cab2..122635e67 100644 --- a/src/components/settings/ai/anthropic-provider-form.tsx +++ b/src/components/settings/ai/anthropic-provider-form.tsx @@ -15,7 +15,7 @@ import { NativeSelect } from "@/components/ui/native-select"; import { PasswordInput } from "@/components/ui/password-input"; import { apiPatch } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; import { ANTHROPIC_MODEL_PRESETS, @@ -79,8 +79,7 @@ export function AnthropicProviderForm({ setOk(true); setMsg(t("settings.ai.saved")); setApiKey(""); - queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() }); - queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); + void invalidateKeys(queryClient, aiInputDependentKeys); }, onError: (e) => { setOk(false); diff --git a/src/components/settings/ai/central-codex-switch.tsx b/src/components/settings/ai/central-codex-switch.tsx index 9e2806825..010366b03 100644 --- a/src/components/settings/ai/central-codex-switch.tsx +++ b/src/components/settings/ai/central-codex-switch.tsx @@ -23,7 +23,7 @@ import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { apiPatch } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; import type { InsightsSettings } from "./shared"; @@ -51,11 +51,9 @@ export function CentralCodexSwitch({ }), onSuccess: () => { // The opt-in changes which providers can serve this user — refresh the - // settings summary (which carries the flag) and every insight read. - queryClient.invalidateQueries({ - queryKey: queryKeys.insightsSettings(), - }); - queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); + // settings summary (which carries the flag), the account's AI + // capability answer, and every insight read. + void invalidateKeys(queryClient, aiInputDependentKeys); setPendingEnable(false); }, }); diff --git a/src/components/settings/ai/codex-provider-form.tsx b/src/components/settings/ai/codex-provider-form.tsx index b53ed9525..2aafecff3 100644 --- a/src/components/settings/ai/codex-provider-form.tsx +++ b/src/components/settings/ai/codex-provider-form.tsx @@ -14,7 +14,7 @@ import { ConfirmButton } from "@/components/ui/confirm-button"; import { apiFetchRaw } from "@/lib/api/api-fetch"; import { formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; import type { InsightsSettings } from "./shared"; @@ -55,7 +55,7 @@ export function CodexProviderForm({ url.searchParams.delete("codex_error"); window.history.replaceState({}, "", url.toString()); if (oauthOutcome.kind === "connected") { - queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); + void invalidateKeys(queryClient, aiInputDependentKeys); } }, [oauthOutcome, queryClient]); @@ -112,7 +112,7 @@ export function CodexProviderForm({ setDevicePolling(false); setMsg(t("settings.codexConnected")); setMsgType("success"); - queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); + void invalidateKeys(queryClient, aiInputDependentKeys); return; } if (!cancelled) setTimeout(tick, intervalMs); @@ -151,7 +151,7 @@ export function CodexProviderForm({ } setMsg(t("settings.codexDisconnected")); setMsgType("success"); - queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); + void invalidateKeys(queryClient, aiInputDependentKeys); } catch (err) { setMsg(err instanceof Error ? err.message : t("settings.savingError")); setMsgType("error"); diff --git a/src/components/settings/ai/compat-provider-form.tsx b/src/components/settings/ai/compat-provider-form.tsx index a91e444a7..d584c10df 100644 --- a/src/components/settings/ai/compat-provider-form.tsx +++ b/src/components/settings/ai/compat-provider-form.tsx @@ -17,7 +17,7 @@ import { Label } from "@/components/ui/label"; import { PasswordInput } from "@/components/ui/password-input"; import { apiPatch } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; import { uiToLegacyProviderEnum, type UserAIProvider } from "./shared"; @@ -63,8 +63,7 @@ export function CompatProviderForm({ setOk(true); setMsg(t("settings.ai.saved")); setApiKey(""); - queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() }); - queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); + void invalidateKeys(queryClient, aiInputDependentKeys); }, onError: (e) => { setOk(false); diff --git a/src/components/settings/ai/disable-coach-card.tsx b/src/components/settings/ai/disable-coach-card.tsx index b75aa198d..4adad60a7 100644 --- a/src/components/settings/ai/disable-coach-card.tsx +++ b/src/components/settings/ai/disable-coach-card.tsx @@ -15,7 +15,7 @@ * a pure UI inversion over the same column. * * Persists via `PATCH /api/auth/me/disable-coach`; the response invalidates - * `queryKeys.authMe()` so every Coach mount point on the client + * `aiInputDependentKeys` (the account payload first) so every Coach mount point on the client * (``, ``, the inline `` * pill, the `/targets` page CTAs) re-renders with the new `user.disableCoach` * value on the next React Query refetch tick — no full reload required. @@ -39,7 +39,7 @@ import { apiPatch } from "@/lib/api/api-fetch"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; export function DisableCoachCard({ isAuthenticated, @@ -115,7 +115,7 @@ export function DisableCoachCard({ // Surface the new value to every Coach gate via the shared // `useAuth()` query — the gates re-render once React Query // re-fetches /api/auth/me with the updated `disableCoach` field. - queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); + void invalidateKeys(queryClient, aiInputDependentKeys); setOptimistic(null); scheduleClear(); }, diff --git a/src/components/settings/ai/fallback-chain-card.tsx b/src/components/settings/ai/fallback-chain-card.tsx index 0631a8f1f..388a04b1a 100644 --- a/src/components/settings/ai/fallback-chain-card.tsx +++ b/src/components/settings/ai/fallback-chain-card.tsx @@ -33,7 +33,7 @@ import { NativeSelect } from "@/components/ui/native-select"; import { Switch } from "@/components/ui/switch"; import { apiPut } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; import { DEFAULT_CHAIN, @@ -99,7 +99,7 @@ export function FallbackChainCard({ onSuccess: () => { setOk(true); setMsg(t("settings.ai.providerChain.saved")); - queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); + void invalidateKeys(queryClient, aiInputDependentKeys); }, onError: (e) => { setOk(false); diff --git a/src/components/settings/ai/local-provider-form.tsx b/src/components/settings/ai/local-provider-form.tsx index 725c02dd0..63c1c5c68 100644 --- a/src/components/settings/ai/local-provider-form.tsx +++ b/src/components/settings/ai/local-provider-form.tsx @@ -15,7 +15,7 @@ import { NativeSelect } from "@/components/ui/native-select"; import { PasswordInput } from "@/components/ui/password-input"; import { apiPatch } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; import { CUSTOM_MODEL_SENTINEL, @@ -79,8 +79,7 @@ export function LocalProviderForm({ setOk(true); setMsg(t("settings.ai.saved")); setApiKey(""); - queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() }); - queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); + void invalidateKeys(queryClient, aiInputDependentKeys); }, onError: (e) => { setOk(false); diff --git a/src/components/settings/ai/openai-provider-form.tsx b/src/components/settings/ai/openai-provider-form.tsx index 92bf3ea8c..84c5f63de 100644 --- a/src/components/settings/ai/openai-provider-form.tsx +++ b/src/components/settings/ai/openai-provider-form.tsx @@ -18,7 +18,7 @@ import { NativeSelect } from "@/components/ui/native-select"; import { PasswordInput } from "@/components/ui/password-input"; import { apiPatch } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; import { CUSTOM_MODEL_SENTINEL, @@ -86,8 +86,7 @@ export function OpenAIProviderForm({ setOk(true); setMsg(t("settings.ai.saved")); setApiKey(""); - queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() }); - queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); + void invalidateKeys(queryClient, aiInputDependentKeys); }, onError: (e) => { setOk(false); diff --git a/src/components/settings/ai/response-timeout-card.tsx b/src/components/settings/ai/response-timeout-card.tsx index 874479ecc..c471694e3 100644 --- a/src/components/settings/ai/response-timeout-card.tsx +++ b/src/components/settings/ai/response-timeout-card.tsx @@ -20,7 +20,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { apiPatch } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; import type { UserAIProvider } from "./shared"; @@ -69,7 +69,7 @@ export function ResponseTimeoutCard({ onSuccess: () => { setOk(true); setMsg(t("settings.ai.saved")); - queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() }); + void invalidateKeys(queryClient, aiInputDependentKeys); }, onError: (e) => { setOk(false); diff --git a/src/components/settings/managed-record-settings-section.tsx b/src/components/settings/managed-record-settings-section.tsx index e612daae2..72639967c 100644 --- a/src/components/settings/managed-record-settings-section.tsx +++ b/src/components/settings/managed-record-settings-section.tsx @@ -12,7 +12,11 @@ import { type InsightsSectionId, } from "@/lib/insights-layout"; import { MODULE_REGISTRY } from "@/lib/modules/registry"; -import { queryKeys } from "@/lib/query-keys"; +import { + aiInputDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; import { assertRecordSettingsResponseForRecord, MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS, @@ -1074,6 +1078,10 @@ export function ManagedRecordSettingsSection({ queryKeys.recordSettingsFamily(recordId, family), response, ); + // The record's module map, Hide Coach and profile (gender gates the + // cycle module) are inputs of the `/me` answer for this record, its AI + // capabilities included. + void invalidateKeys(queryClient, aiInputDependentKeys); }, }); diff --git a/src/components/settings/mcp-section.tsx b/src/components/settings/mcp-section.tsx index bbe832620..bbe1c063d 100644 --- a/src/components/settings/mcp-section.tsx +++ b/src/components/settings/mcp-section.tsx @@ -44,7 +44,11 @@ import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useAuth } from "@/hooks/use-auth"; import { formatDate, formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { + aiInputDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; import { apiFetchRaw, apiGet, apiPatch } from "@/lib/api/api-fetch"; interface McpTokenInfo { @@ -211,7 +215,7 @@ function McpEnableCard() { mutationFn: async (next: boolean) => apiPatch("/api/auth/me/modules", { mcp: next }), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); + void invalidateKeys(queryClient, aiInputDependentKeys); toast.success(t("settings.sections.modules.saved")); }, onError: () => toast.error(t("settings.sections.modules.error")), diff --git a/src/components/settings/modules-section.tsx b/src/components/settings/modules-section.tsx index f69ec9b98..f21a8efa2 100644 --- a/src/components/settings/modules-section.tsx +++ b/src/components/settings/modules-section.tsx @@ -71,7 +71,11 @@ import { withBaseToken, isConflict, } from "@/lib/api/optimistic-token"; -import { queryKeys } from "@/lib/query-keys"; +import { + aiInputDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; import { MODULE_REGISTRY, isCodeDisabledModule, @@ -170,7 +174,8 @@ export function ModulesSection() { // Insights pills, dashboard tiles, quick-add, search. The two delegated // keys also feed dedicated settings surfaces, so evict their reads too // (mirrors the canonical Coach / cycle cards' own invalidation). - void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); + // The module map is also an input of the AI capability answer. + void invalidateKeys(queryClient, aiInputDependentKeys); if (moduleDelegatesTo(vars.key) === "cycle") { void queryClient.invalidateQueries({ queryKey: queryKeys.cyclePrefs(), diff --git a/src/hooks/use-grant-document-reading-consent.ts b/src/hooks/use-grant-document-reading-consent.ts index b43f673b9..988b4312f 100644 --- a/src/hooks/use-grant-document-reading-consent.ts +++ b/src/hooks/use-grant-document-reading-consent.ts @@ -3,7 +3,11 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiPost } from "@/lib/api/api-fetch"; -import { queryKeys } from "@/lib/query-keys"; +import { + aiInputDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; /** * Grant the consent for reading documents (`ai_extraction`) from the place @@ -38,7 +42,7 @@ export function useGrantDocumentReadingConsent() { }); }, onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); + void invalidateKeys(queryClient, aiInputDependentKeys); void queryClient.invalidateQueries({ queryKey: queryKeys.aiConsentReceipt("ai_extraction"), }); diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index 3fc184256..ed83c7676 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -302,6 +302,29 @@ export const vaccinationDependentKeys = [ queryKeys.measurementReminders(), ]; +/** + * Keys invalidated when an input of the `ai` block on `GET /api/auth/me` + * changes: the account's own provider (key, local model, compatible endpoint, + * Codex sign-in), the fallback chain, the central-Codex opt-in, a consent + * receipt granted or withdrawn, a module switch, Hide Coach, and, for the + * admin's own session, the operator's assistant switches, server key, central + * Codex and module availability. + * + * Every AI surface reads its capability from that block, so a write that + * changes an input without evicting `authMe()` leaves the surface on the old + * answer: a freshly configured provider stays "not set up" until a reload, a + * removed one keeps offering AI work the server then refuses. The provider and + * insights roots ride along because they render the same state in detail. + * + * `ai-input-invalidation-guard.test.ts` holds every client write to one of + * those endpoints to this bundle. + */ +export const aiInputDependentKeys = [ + queryKeys.authMe(), + queryKeys.userAiProvider(), + queryKeys.insightsRoot(), +]; + /** * Invalidate every key in the bundle in parallel. Use this from mutation * `onSuccess` handlers so the call site stays a one-liner instead of repeating From 28d491174e72b73baa3fc6958de1a70be58718c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 12:13:15 +0200 Subject: [PATCH 03/11] fix(onboarding): keep the tour from bouncing between the Coach and Insights With no provider set up, a fresh self-host's default, the Coach module is on but /coach redirects to /insights because the Coach is not available. The tour read the raw module map, kept its Coach stop, pushed /coach, was sent back to /insights and pushed /coach again on the pathname change: a loop. The tour now reads the same capability-folded map as the navigation, so the Coach stop is not in the list. And a stop whose page redirects is dropped after one navigation, in the direction the person was going, instead of being pushed again. --- .../__tests__/tour-launcher-gate.test.ts | 22 ++++ src/components/onboarding/tour-launcher.tsx | 11 +- src/components/onboarding/tour.tsx | 57 +++++++++- .../onboarding/__tests__/tour-state.test.ts | 103 ++++++++++++++++++ src/lib/onboarding/tour-state.ts | 43 ++++++++ 5 files changed, 231 insertions(+), 5 deletions(-) diff --git a/src/components/onboarding/__tests__/tour-launcher-gate.test.ts b/src/components/onboarding/__tests__/tour-launcher-gate.test.ts index 635733acf..71b8cc4b0 100644 --- a/src/components/onboarding/__tests__/tour-launcher-gate.test.ts +++ b/src/components/onboarding/__tests__/tour-launcher-gate.test.ts @@ -13,6 +13,8 @@ * regress the gate. v1.18.6.1 — the tour is first-time-auto-start * only; the former "manual restart from Settings" bypass was removed. */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { @@ -147,3 +149,23 @@ describe("tourIncludesAchievements() — v1.18.0 B5 module gate", () => { expect(tourIncludesAchievements({ sleep: false, mood: false })).toBe(true); }); }); + +describe("the launcher hands the tour the capability-folded module map", () => { + // The raw `user.modules` says the Coach module is on for a fresh self-host + // with no provider, while `/coach` redirects to `/insights` because the + // Coach capability is unavailable: the tour pushed `/coach`, was sent back, + // and pushed again. The launcher must read `useNavModules()`, which folds + // the capability into `coach`, exactly as the nav does. + const source = readFileSync( + join(process.cwd(), "src/components/onboarding/tour-launcher.tsx"), + "utf8", + ); + + it("reads useNavModules()", () => { + expect(source).toMatch(/\buseNavModules\(\)/); + }); + + it("does not pass the raw account module map to the tour", () => { + expect(source).not.toMatch(/modules\s*=\s*user\?\.modules/); + }); +}); diff --git a/src/components/onboarding/tour-launcher.tsx b/src/components/onboarding/tour-launcher.tsx index d8d50376d..096265875 100644 --- a/src/components/onboarding/tour-launcher.tsx +++ b/src/components/onboarding/tour-launcher.tsx @@ -44,6 +44,7 @@ import { useCallback, useEffect, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; +import { useNavModules } from "@/hooks/use-nav-modules"; import { apiPost } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; import { OnboardingTour } from "./tour"; @@ -217,6 +218,7 @@ interface TourLauncherProps { export function TourLauncher({ ready = true }: TourLauncherProps) { const { user, isAuthenticated, isLoading } = useAuth(); + const navModules = useNavModules(); const queryClient = useQueryClient(); const pathname = usePathname(); @@ -350,9 +352,12 @@ export function TourLauncher({ ready = true }: TourLauncherProps) { if (showTour !== true) return null; - // The resolved module map gates which stops appear (default-on) and - // keeps the "Schritt n/total" counter honest. - const modules = user?.modules; + // The module map the nav reads gates which stops appear (default-on) and + // keeps the "Schritt n/total" counter honest. It folds the Coach + // capability in: with no provider or no consent the Coach module is on + // but `/coach` redirects, so the raw `user.modules` would keep a stop the + // tour can never stand on. + const modules = navModules; // v1.18.6 — resume the full tour from the persisted checkpoint. const resumeFromStopId = user?.onboardingTourProgress?.lastStopId ?? null; diff --git a/src/components/onboarding/tour.tsx b/src/components/onboarding/tour.tsx index 1993b694e..72276d6d0 100644 --- a/src/components/onboarding/tour.tsx +++ b/src/components/onboarding/tour.tsx @@ -51,6 +51,8 @@ import { prevStep, skipTour, stepCounter, + dropRedirectedStop, + stopRouteRedirected, type TourModuleMap, type TourState, type TourStop, @@ -257,6 +259,21 @@ export function OnboardingTour({ initTourState(stops, filterToStop ? null : resumeFromStopId), ); const [rect, setRect] = useState(null); + // Which stop the tour last navigated for, from where, and whether that + // stop's page was reached; and which way the person was travelling, so a + // stop whose page redirects is stepped past in the same direction. + const navRef = useRef<{ + stopId: string | null; + pushedFrom: string | null; + arrived: boolean; + }>({ stopId: null, pushedFrom: null, arrived: false }); + const directionRef = useRef<"forward" | "back">("forward"); + // The navigation effect reads the latest `onClose` without re-running on + // its identity (the launcher passes a fresh closure every render). + const onCloseRef = useRef(onClose); + useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); const stop = currentStop(state); @@ -285,8 +302,42 @@ export function OnboardingTour({ // stops (the two dashboard stops while on `/`, or the wrap-up which has // no route) skip the push. `filterToStop` re-entry never navigates — it // runs on the page the user is already viewing. - if (!filterToStop && stop.route && stop.route !== pathname) { - router.push(stop.route); + // + // One push per stop. A page that sends the person elsewhere (the Coach + // page redirects to `/insights` while the Coach is unavailable) would + // otherwise be pushed again on every pathname change, a loop between the + // two pages; such a stop is dropped and the tour moves on. + if (!filterToStop && stop.route) { + if (navRef.current.stopId !== stop.id) { + navRef.current = { stopId: stop.id, pushedFrom: null, arrived: false }; + } + const nav = navRef.current; + if (pathname === stop.route) { + nav.arrived = true; + } else if ( + stopRouteRedirected({ + stopRoute: stop.route, + pathname, + pushedFrom: nav.pushedFrom, + arrived: nav.arrived, + }) + ) { + const direction = directionRef.current; + queueMicrotask(() => + setState((prev) => { + if (currentStop(prev)?.id !== stop.id) return prev; + const next = dropRedirectedStop(prev, direction); + if (isTourFinished(next)) { + queueMicrotask(() => onCloseRef.current("completed")); + } + return next; + }), + ); + return; + } else if (nav.pushedFrom === null) { + nav.pushedFrom = pathname; + router.push(stop.route); + } } const measureNow = () => { @@ -397,6 +448,7 @@ export function OnboardingTour({ }, [onClose]); const handleNext = useCallback(() => { + directionRef.current = "forward"; setState((prev) => { const next = nextStep(prev); if (isTourFinished(next) && next.outcome === "completed") { @@ -407,6 +459,7 @@ export function OnboardingTour({ }, [onClose]); const handlePrev = useCallback(() => { + directionRef.current = "back"; setState((prev) => prevStep(prev)); }, []); diff --git a/src/lib/onboarding/__tests__/tour-state.test.ts b/src/lib/onboarding/__tests__/tour-state.test.ts index 94e7a414c..f71ea0423 100644 --- a/src/lib/onboarding/__tests__/tour-state.test.ts +++ b/src/lib/onboarding/__tests__/tour-state.test.ts @@ -10,6 +10,8 @@ import { prevStep, skipTour, stepCounter, + dropRedirectedStop, + stopRouteRedirected, } from "../tour-state"; const FULL_ORDER = [ @@ -204,3 +206,104 @@ describe("tour-state", () => { }); }); }); + +describe("a stop whose page sends the person elsewhere", () => { + const coach = "/coach"; + + it("is not redirected before the tour navigated for it", () => { + expect( + stopRouteRedirected({ + stopRoute: coach, + pathname: "/insights", + pushedFrom: null, + arrived: false, + }), + ).toBe(false); + }); + + it("is not redirected while the navigation is still on its way", () => { + expect( + stopRouteRedirected({ + stopRoute: coach, + pathname: "/insights", + pushedFrom: "/insights", + arrived: false, + }), + ).toBe(false); + }); + + it("is not redirected once it stands on its page", () => { + expect( + stopRouteRedirected({ + stopRoute: coach, + pathname: coach, + pushedFrom: "/insights", + arrived: true, + }), + ).toBe(false); + }); + + it("is redirected when its page sent the person back to where they came from", () => { + // The Coach page answers an unavailable Coach with a redirect to + // /insights, the previous stop's page: /insights → /coach → /insights. + expect( + stopRouteRedirected({ + stopRoute: coach, + pathname: "/insights", + pushedFrom: "/insights", + arrived: true, + }), + ).toBe(true); + }); + + it("is redirected when the navigation landed on a third page", () => { + expect( + stopRouteRedirected({ + stopRoute: coach, + pathname: "/", + pushedFrom: "/insights", + arrived: false, + }), + ).toBe(true); + }); + + it("is dropped going forward: the next stop takes its place and Back never lands on it", () => { + const steps = buildTourStops(); + const at = steps.findIndex((s) => s.id === "coach"); + const state = dropRedirectedStop( + { index: at, steps, outcome: null }, + "forward", + ); + expect(currentStop(state)?.id).toBe("integrations"); + expect(state.steps.map((s) => s.id)).not.toContain("coach"); + expect(currentStop(prevStep(state))?.id).toBe("insights"); + }); + + it("is dropped going back: the previous stop takes over", () => { + const steps = buildTourStops(); + const at = steps.findIndex((s) => s.id === "coach"); + const state = dropRedirectedStop( + { index: at, steps, outcome: null }, + "back", + ); + expect(currentStop(state)?.id).toBe("insights"); + }); + + it("completes the tour when the dropped stop was the last one", () => { + const steps = buildTourStops().slice(0, 3); + const state = dropRedirectedStop( + { index: 2, steps, outcome: null }, + "forward", + ); + expect(state.outcome).toBe("completed"); + }); +}); + +describe("the tour's Coach stop follows the Coach's capability", () => { + it("drops the Coach stop from a capability-folded module map", () => { + // `useNavModules()` folds an unavailable Coach capability (no provider, + // no consent) into `coach: false`; the tour reads that map. + const ids = buildTourStops({ modules: { coach: false } }).map((s) => s.id); + expect(ids).not.toContain("coach"); + }); +}); diff --git a/src/lib/onboarding/tour-state.ts b/src/lib/onboarding/tour-state.ts index 00d7c8f5c..d54f00c41 100644 --- a/src/lib/onboarding/tour-state.ts +++ b/src/lib/onboarding/tour-state.ts @@ -289,6 +289,49 @@ export function prevStep(state: TourState): TourState { return { ...state, index: state.index - 1 }; } +/** + * Whether the page a stop navigated to sent the person somewhere else. + * + * A stop's page can refuse the visit on its own (the Coach page answers an + * unavailable Coach with a redirect to `/insights`), and the overlay would + * otherwise push the stop's route again on every pathname change: a loop + * between the two pages. `pushedFrom` is the pathname the tour navigated + * from for this stop (`null` while it has not navigated), `arrived` whether + * the stop's own page was reached since. Before arrival, a pathname still + * equal to `pushedFrom` is a navigation on its way, not a redirect. + */ +export function stopRouteRedirected(args: { + stopRoute: string; + pathname: string; + pushedFrom: string | null; + arrived: boolean; +}): boolean { + if (args.pushedFrom === null) return false; + if (args.pathname === args.stopRoute) return false; + return args.arrived || args.pathname !== args.pushedFrom; +} + +/** + * Drop the current stop after its page redirected, and carry on in the + * direction the person was travelling. The stop leaves the list, so Back + * never lands on it again and the counter stays honest; dropping the last + * stop going forward completes the tour. + */ +export function dropRedirectedStop( + state: TourState, + direction: "forward" | "back", +): TourState { + if (state.outcome !== null) return state; + const steps = state.steps.filter((_, i) => i !== state.index); + if (direction === "back") { + return { ...state, steps, index: Math.max(0, state.index - 1) }; + } + if (state.index >= steps.length) { + return { ...state, steps, index: steps.length, outcome: "completed" }; + } + return { ...state, steps }; +} + /** User explicitly dismissed the tour from any step. */ export function skipTour(state: TourState): TourState { if (state.outcome !== null) return state; From 15cce33a286c7ad3d485844527a328e3947e864b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 12:15:36 +0200 Subject: [PATCH 04/11] fix(documents): keep the link pickers from deleting a link made elsewhere A dose or a visit can link documents from its own form, and a document's sheet can link doses and visits; both ends write the same table, and the sheet writes the whole set on every tap. Dose and visit writes left the document reads alone, and the sheet seeded its pickers from a detail cached for five minutes, so the first tap there after linking from the dose's form wrote a set without the new link and removed it. Dose and visit writes now evict the document root, the sheet reads the document again on every open, and its pickers wait for that read before they offer a change. --- .../__tests__/document-record-links.test.tsx | 25 ++++++++++++++- .../documents/document-detail-sheet.tsx | 22 +++++++++++++ .../documents/document-record-links.tsx | 32 +++++++++++++++---- .../__tests__/link-dependent-keys.test.ts | 25 +++++++++++++++ src/lib/query-keys/index.ts | 8 +++++ 5 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 src/lib/query-keys/__tests__/link-dependent-keys.test.ts diff --git a/src/components/documents/__tests__/document-record-links.test.tsx b/src/components/documents/__tests__/document-record-links.test.tsx index c93b34c62..d06b0653e 100644 --- a/src/components/documents/__tests__/document-record-links.test.tsx +++ b/src/components/documents/__tests__/document-record-links.test.tsx @@ -103,7 +103,12 @@ describe("recordOptions", () => { describe("", () => { it("lets a manager link the page to doses and visits", () => { const html = render( - undefined} />, + undefined} + />, ); expect(html).toContain('data-slot="document-vaccination-links"'); expect(html).toContain('data-slot="document-vaccination-links-add"'); @@ -124,6 +129,7 @@ describe("", () => { ], })} canManage={false} + seedFresh onChange={() => undefined} />, ); @@ -137,9 +143,26 @@ describe("", () => { undefined} />, ); expect(html).not.toContain("document-vaccination-links"); }); + + it("offers no picker until the document has been read since the sheet opened", () => { + // Each change is a replace-set write seeded from the document's links. + // Seeded from a cached copy that predates a link made on the dose's or + // the visit's own form, the first tap would silently delete that link. + const html = render( + undefined} + />, + ); + expect(html).not.toContain('data-slot="document-vaccination-links-add"'); + expect(html).not.toContain('data-slot="document-visit-links-add"'); + }); }); diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index c1b216982..780105bfa 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -353,11 +353,30 @@ export function DocumentDetailSheet({ const detail = useQuery({ queryKey: queryKeys.inboundDocument(documentId ?? "none"), enabled: open && documentId !== null, + // Always read again on open: the link pickers below write replace-sets + // seeded from this copy (see `seedFresh`). + staleTime: 0, queryFn: () => apiGet(`/api/documents/inbound/${documentId}`), }); const doc = detail.data; + // The detail's timestamp when the sheet opened on this document. A read + // that lands after it is fresh enough to seed the link pickers from; a + // cached copy from before the sheet opened is not. + const [openedOn, setOpenedOn] = useState<{ + id: string; + seenAt: number; + } | null>(null); + const openKey = open && documentId !== null ? documentId : null; + if (openKey !== (openedOn?.id ?? null)) { + setOpenedOn( + openKey === null ? null : { id: openKey, seenAt: detail.dataUpdatedAt }, + ); + } + const linkSeedFresh = + openedOn !== null && detail.dataUpdatedAt > openedOn.seenAt; + // 2026-07-17 UX/IA audit M5 / F5-5 — the document → lab-values direction // had no UI link at all: an auto-staged (PENDING) or OCR-confirmed // (APPROVED, see `linkOcrLabsToVaultDocument`) OBSERVATION fact already @@ -1111,6 +1130,9 @@ export function DocumentDetailSheet({ key={`${doc.id}:${linkResets}`} doc={doc} canManage={canManageDocuments} + seedFresh={linkSeedFresh} + seedError={!linkSeedFresh && detail.isRefetchError} + onRetrySeed={() => void detail.refetch()} onChange={(part) => patch.mutate(part)} /> diff --git a/src/components/documents/document-record-links.tsx b/src/components/documents/document-record-links.tsx index 7899443c2..cbf9e0b0a 100644 --- a/src/components/documents/document-record-links.tsx +++ b/src/components/documents/document-record-links.tsx @@ -91,10 +91,24 @@ function doseName( export function DocumentRecordLinks({ doc, canManage, + seedFresh, + seedError = false, + onRetrySeed, onChange, }: { doc: InboundDocumentDetailDto; canManage: boolean; + /** + * Whether `doc` was read from the server since the sheet opened. Every + * change is a replace-set write seeded from `doc`'s links, so a seed from a + * cached copy that predates a link made on the dose's or the visit's own + * form would delete that link on the first tap. The pickers wait for a + * fresh read instead. + */ + seedFresh: boolean; + /** That fresh read failed; the pickers show the retry row instead. */ + seedError?: boolean; + onRetrySeed?: () => void; onChange: (part: { encounterIds?: string[]; vaccinationIds?: string[]; @@ -155,10 +169,13 @@ export function DocumentRecordLinks({ icon={CalendarDays} title={t("documents.detail.visitsLabel")} slot="document-visit-links" - pending={encounters.isPending} - error={encounters.isError} + pending={encounters.isPending || !seedFresh} + error={encounters.isError || seedError} errorLabel={t("links.picker.loadError")} - onRetry={() => void encounters.refetch()} + onRetry={() => { + if (encounters.isError) void encounters.refetch(); + if (seedError) onRetrySeed?.(); + }} selected={visitIds ?? doc.encounterLinks.map((l) => l.encounterId)} onChange={(ids) => { setVisitIds(ids); @@ -199,10 +216,13 @@ export function DocumentRecordLinks({ icon={Syringe} title={t("documents.detail.vaccinationsLabel")} slot="document-vaccination-links" - pending={vaccinations.isPending} - error={vaccinations.isError} + pending={vaccinations.isPending || !seedFresh} + error={vaccinations.isError || seedError} errorLabel={t("links.picker.loadError")} - onRetry={() => void vaccinations.refetch()} + onRetry={() => { + if (vaccinations.isError) void vaccinations.refetch(); + if (seedError) onRetrySeed?.(); + }} selected={ doseIds ?? (doc.vaccinationLinks ?? []).map((link) => link.vaccinationId) diff --git a/src/lib/query-keys/__tests__/link-dependent-keys.test.ts b/src/lib/query-keys/__tests__/link-dependent-keys.test.ts new file mode 100644 index 000000000..e6654cc6e --- /dev/null +++ b/src/lib/query-keys/__tests__/link-dependent-keys.test.ts @@ -0,0 +1,25 @@ +/** + * A visit or a dose written from its own form can link or unlink documents, + * and the document's sheet seeds its replace-set pickers from its cached + * links. A record write that left the document reads alone kept that seed + * stale, and the next tap on the sheet deleted the new link. Both record + * bundles therefore evict the document root, and the document's own link + * write evicts the record roots (pinned in `document-detail-sheet`). + */ +import { describe, expect, it } from "vitest"; + +import { + encounterDependentKeys, + queryKeys, + vaccinationDependentKeys, +} from "@/lib/query-keys"; + +describe("record writes reach the document reads", () => { + it("a dose write evicts the documents root", () => { + expect(vaccinationDependentKeys).toContainEqual(queryKeys.documents()); + }); + + it("a visit write evicts the documents root", () => { + expect(encounterDependentKeys).toContainEqual(queryKeys.documents()); + }); +}); diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index ed83c7676..861b4b276 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -281,6 +281,10 @@ export const encounterDependentKeys = [ // `invalidateReminderReads` helper, which carries it) — the digest is // typically unmounted while the user is on the visits surface. queryKeys.dailyDigest(), + // A visit's form links and unlinks documents, and the document's sheet + // seeds its replace-set visit picker from its own cached links: left + // stale, the next tap there would delete the link just made. + queryKeys.documents(), ]; /** @@ -300,6 +304,10 @@ export const encounterDependentKeys = [ export const vaccinationDependentKeys = [ queryKeys.vaccinations(), queryKeys.measurementReminders(), + // A dose's form links and unlinks documents, and the document's sheet + // seeds its replace-set dose picker from its own cached links: left stale, + // the next tap there would delete the link just made. + queryKeys.documents(), ]; /** From aeb8930ce150f0aec5cf31550c42898c7223bdc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 12:16:57 +0200 Subject: [PATCH 05/11] test(e2e): assert slots the AI-off overview can actually paint Three assertions in the AI-optional overview spec named slots that no longer exist anywhere in the tree, so they held whatever the page did. They now name the error primitives the overview paints on a refused read and the Coach hand-offs that paint only while the Coach is available, and the no-provider journey checks that /coach goes back to the overview. --- e2e/ai-optional-overview.spec.ts | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/e2e/ai-optional-overview.spec.ts b/e2e/ai-optional-overview.spec.ts index 69c8bac60..dac4533bd 100644 --- a/e2e/ai-optional-overview.spec.ts +++ b/e2e/ai-optional-overview.spec.ts @@ -6,9 +6,9 @@ * 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. + * the overview, no Coach launcher or per-card Coach hand-off, and + * `/coach` goes back to the overview. 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 @@ -90,12 +90,13 @@ test.describe("J1: AI switched off by the operator", () => { // 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); + // A read refused for want of AI paints one of these on the overview: + // the section boundary, the error card, or the dense error row. await expect( page.locator('[data-slot="insights-overview-section-error"]'), ).toHaveCount(0); + await expect(page.locator('[data-slot="query-error-card"]')).toHaveCount(0); + await expect(page.locator('[data-slot="query-error-row"]')).toHaveCount(0); await expect(page.locator('[data-slot="chart-error-state"]')).toHaveCount( 0, ); @@ -138,12 +139,17 @@ test.describe("J3: no AI provider", () => { 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); + // The per-card Coach hand-offs paint only while the Coach is available. + await expect(page.locator('[data-slot="ask-coach-action"]')).toHaveCount(0); + await expect(page.locator('[data-slot="ask-coach-icon"]')).toHaveCount(0); + await expect(page.locator('[data-slot="coach-launch-inline"]')).toHaveCount( + 0, + ); + + // The Coach page itself answers an unavailable Coach by going back to + // the overview, rather than painting a composer that would be refused. + await page.goto("/coach"); + await expect(page).toHaveURL(/\/insights$/, { timeout: 20_000 }); // Settings → AI carries the same hint; dismissing it there is remembered. await page.goto("/settings/ai"); From a2282a498c60ff24b4c60fe95b2a0a55b592593e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 12:17:56 +0200 Subject: [PATCH 06/11] fix(encounters): give the visit form's link pickers their own query keys The visit form's lab and episode pickers fetched with their own page sizes under the same keys as the Labs list and the illness pages, which fetch with others. Whichever read landed first served the other: the picker could see 50 episodes instead of 100, or the Labs list a 200-row page as the whole list. The picker reads now have keys of their own, under the lab and illness roots so writes still evict them. --- .../encounters/encounter-link-pickers.tsx | 11 +---- .../__tests__/picker-list-keys.test.ts | 41 +++++++++++++++++++ src/lib/query-keys/illness.ts | 4 ++ src/lib/query-keys/labs.ts | 6 +++ 4 files changed, 53 insertions(+), 9 deletions(-) create mode 100644 src/lib/query-keys/__tests__/picker-list-keys.test.ts diff --git a/src/components/encounters/encounter-link-pickers.tsx b/src/components/encounters/encounter-link-pickers.tsx index f215f2410..dcdbea60f 100644 --- a/src/components/encounters/encounter-link-pickers.tsx +++ b/src/components/encounters/encounter-link-pickers.tsx @@ -87,14 +87,7 @@ export function EncounterLinkPickers({ }); const labs = useQuery({ - queryKey: queryKeys.labResultsList({ - analyte: undefined, - panel: undefined, - from: undefined, - to: undefined, - page: 0, - sortDir: "desc", - }), + queryKey: queryKeys.labResultsPicker(PICKER_FETCH_LIMIT), enabled: labsOn, queryFn: () => apiGet( @@ -103,7 +96,7 @@ export function EncounterLinkPickers({ }); const episodes = useQuery({ - queryKey: queryKeys.illnessEpisodes(true), + queryKey: queryKeys.illnessEpisodesPicker(EPISODE_FETCH_LIMIT), enabled: illnessOn, queryFn: () => apiGet( diff --git a/src/lib/query-keys/__tests__/picker-list-keys.test.ts b/src/lib/query-keys/__tests__/picker-list-keys.test.ts new file mode 100644 index 000000000..ec41804a9 --- /dev/null +++ b/src/lib/query-keys/__tests__/picker-list-keys.test.ts @@ -0,0 +1,41 @@ +/** + * One key, one fetch. The visit form's link pickers read labs and illness + * episodes with their own page size (200 results, 100 episodes), the Labs + * list and the illness hook with theirs (500, the route's default 50). Under + * a shared key whichever read landed first served the other, so the picker + * could offer 50 episodes or the Labs list could render a 200-row page as + * the whole list. The picker reads carry their own keys, still under the + * domain roots so a lab or episode write evicts them. + */ +import { describe, expect, it } from "vitest"; + +import { queryKeys } from "@/lib/query-keys"; + +const LIST_PARAMS = { + analyte: undefined, + panel: undefined, + from: undefined, + to: undefined, + page: 0, + sortDir: "desc", +} as const; + +describe("the link pickers' reads have keys of their own", () => { + it("labs: differs from the Labs list and sits under the lab root", () => { + const key = queryKeys.labResultsPicker(200); + expect(key).not.toEqual(queryKeys.labResultsList(LIST_PARAMS)); + expect(key.slice(0, 1)).toEqual(queryKeys.labResults()); + }); + + it("episodes: differs from the illness list and sits under the illness root", () => { + const key = queryKeys.illnessEpisodesPicker(100); + expect(key).not.toEqual(queryKeys.illnessEpisodes(true)); + expect(key.slice(0, 1)).toEqual(queryKeys.illness()); + }); + + it("a different page size is a different key", () => { + expect(queryKeys.labResultsPicker(200)).not.toEqual( + queryKeys.labResultsPicker(500), + ); + }); +}); diff --git a/src/lib/query-keys/illness.ts b/src/lib/query-keys/illness.ts index 751180365..ce5b27231 100644 --- a/src/lib/query-keys/illness.ts +++ b/src/lib/query-keys/illness.ts @@ -15,6 +15,10 @@ export const illnessKeys = { illness: () => ["illness"] as const, illnessEpisodes: (includeResolved: boolean) => ["illness", "episodes", includeResolved] as const, + // The visit form's episode picker, with its own page size (the illness + // pages read the route's default); a shared key would serve one to the other. + illnessEpisodesPicker: (limit: number) => + ["illness", "episodes-picker", limit] as const, illnessEpisode: (id: string) => ["illness", "episode", id] as const, illnessDayLog: (episodeId: string, date: string) => ["illness", "day-log", episodeId, date] as const, diff --git a/src/lib/query-keys/labs.ts b/src/lib/query-keys/labs.ts index 6607231d5..cbb3f7031 100644 --- a/src/lib/query-keys/labs.ts +++ b/src/lib/query-keys/labs.ts @@ -29,6 +29,12 @@ export const labKeys = { params.sortDir, ] as const, + // The visit form's lab picker: newest results, its own page size. Its own + // key because the Labs list reads a different page size, and a shared key + // would serve one read's page to the other. + labResultsPicker: (limit: number) => + ["lab-results", "picker", limit] as const, + // v1.25 — paginated (offset) reading feed for the biomarker detail page. // Distinct from `labResultsList` (single-page key) so the infinite-query // accumulation never collides with a single-page read. Shares the From 8d60690b4ab9aa2cc59ce7f0e5d59bfdf0477635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 12:18:24 +0200 Subject: [PATCH 07/11] fix(insights): give the setup hint's dismiss button a 44 px target on phones --- .../__tests__/ai-setup-hint-target.test.tsx | 48 +++++++++++++++++++ src/components/insights/ai-setup-hint.tsx | 4 +- 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 src/components/insights/__tests__/ai-setup-hint-target.test.tsx diff --git a/src/components/insights/__tests__/ai-setup-hint-target.test.tsx b/src/components/insights/__tests__/ai-setup-hint-target.test.tsx new file mode 100644 index 000000000..6e06357f6 --- /dev/null +++ b/src/components/insights/__tests__/ai-setup-hint-target.test.tsx @@ -0,0 +1,48 @@ +/** + * The overview's setup hint closes with an icon button in its header. On a + * phone it is the only control on the tile a thumb has to hit, so it carries + * the 44 px floor every other mobile action does (`size-11`), stepping down + * from `sm` like the rest of the app. + */ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: { id: "u1" }, isAuthenticated: true }), +})); +vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); +vi.mock("@/hooks/use-ai-capability", () => ({ + useAiProviderState: () => ({ + configured: false, + managedBy: null, + canConfigure: true, + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { AiSetupHint } from "../ai-setup-hint"; + +function dismissButton(): string { + vi.stubGlobal("window", { + localStorage: { getItem: () => null, setItem: () => undefined }, + }); + const html = renderToStaticMarkup( + + + , + ); + vi.unstubAllGlobals(); + const match = html.match( + /]*data-slot="ai-setup-hint-dismiss"[^>]*>/, + ); + expect(match).not.toBeNull(); + return match![0]; +} + +describe("the setup hint's dismiss button", () => { + it("is at least 44 px on a phone", () => { + const button = dismissButton(); + const cls = button.match(/class="([^"]*)"/)![1]!.split(/\s+/); + expect(cls).toContain("size-11"); + }); +}); diff --git a/src/components/insights/ai-setup-hint.tsx b/src/components/insights/ai-setup-hint.tsx index 6e6ea316c..cf4f3e008 100644 --- a/src/components/insights/ai-setup-hint.tsx +++ b/src/components/insights/ai-setup-hint.tsx @@ -115,7 +115,9 @@ export function AiSetupHint({ aria-label={t("insights.aiSetupHint.dismiss")} title={t("insights.aiSetupHint.dismiss")} data-slot="ai-setup-hint-dismiss" - className="-my-2 shrink-0" + // 44 px on a phone like every other mobile action; the negative + // margin keeps the header row as tall as the title needs. + className="-my-2.5 size-11 shrink-0 sm:-my-2 sm:size-10" >