Merge branch 'fix/v139-review-client' into release/v1.39.0

This commit is contained in:
Marc-André Bombeck committed 2026-09-24 12:32:31 +02:00
commit 503e69488a
62 files changed
+1335 -187

No files matched your search

+18 -12
View File
@@ -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");
+9 -3
View File
@@ -5842,9 +5842,9 @@
"consent": {
"title": "KI-Einwilligung",
"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.",
"withdrawn": "Widerrufen. Sofern keine andere Einwilligung sie abdeckt, nutzen Coach und Insights den KI-Zugang dieses Servers nicht mehr, und kein Online-Modell liest deine Dokumente. Mit deinem eigenen Schlüssel oder einem lokalen Modell laufen Coach und Insights weiter.",
"withdraw": "Einwilligung widerrufen",
"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.",
"confirmBody": "Sofern keine andere Einwilligung sie noch abdeckt, endet sofort: der Coach und die Insights, wo sie den KI-Zugang dieses Servers nutzen, und das Lesen von Dokumenten mit einem Online-Modell. Mit deinem eigenen Schlüssel oder einem lokalen Modell laufen Coach und Insights weiter. Sofern keine andere Einwilligung die Insights noch abdeckt, werden die Notizen gelöscht, die ein Modell für dich geschrieben hat: 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",
@@ -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": {
+9 -3
View File
@@ -5842,9 +5842,9 @@
"consent": {
"title": "AI consent",
"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.",
"withdrawn": "Withdrawn. Unless another consent covers them, the Coach and Insights no longer use the AI access this server provides, and no online model reads your documents. On your own key or a local model, the Coach and Insights keep working.",
"withdraw": "Withdraw consent",
"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.",
"confirmBody": "Unless another consent you gave still covers them, these stop straight away: the Coach and Insights where they use the AI access this server provides, and reading documents with an online model. On your own key or a local model, the Coach and Insights keep working. Unless another consent still covers Insights, 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",
@@ -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": {
+9 -3
View File
@@ -5842,9 +5842,9 @@
"consent": {
"title": "Consentimiento de IA",
"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.",
"withdrawn": "Retirado. Salvo que otro consentimiento lo cubra, el Coach e Insights ya no usan el acceso de IA de este servidor y ningún modelo en línea lee tus documentos. Con tu propia clave o un modelo local, el Coach e Insights siguen funcionando.",
"withdraw": "Retirar el consentimiento",
"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.",
"confirmBody": "Salvo que otro consentimiento tuyo los siga cubriendo, se detienen de inmediato: el Coach e Insights cuando usan el acceso de IA de este servidor, y la lectura de documentos con un modelo en línea. Con tu propia clave o un modelo local, el Coach e Insights siguen funcionando. Salvo que otro consentimiento siga cubriendo Insights, 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",
@@ -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": {
+9 -3
View File
@@ -5842,9 +5842,9 @@
"consent": {
"title": "Consentement IA",
"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.",
"withdrawn": "Retiré. Sauf si un autre consentement le couvre, le Coach et Insights n'utilisent plus l'accès IA fourni par ce serveur, et aucun modèle en ligne ne lit vos documents. Avec votre propre clé ou un modèle local, le Coach et Insights continuent de fonctionner.",
"withdraw": "Retirer le consentement",
"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.",
"confirmBody": "Sauf si un autre consentement les couvre encore, ceci s'arrête immédiatement : le Coach et Insights lorsqu'ils utilisent l'accès IA fourni par ce serveur, et la lecture de documents par un modèle en ligne. Avec votre propre clé ou un modèle local, le Coach et Insights continuent de fonctionner. Sauf si un autre consentement couvre encore Insights, 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",
@@ -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": {
+9 -3
View File
@@ -5842,9 +5842,9 @@
"consent": {
"title": "Consenso IA",
"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.",
"withdrawn": "Revocato. Salvo che un altro consenso lo copra, il Coach e Insights non usano più l'accesso IA fornito da questo server e nessun modello online legge i tuoi documenti. Con la tua chiave o un modello locale, il Coach e Insights continuano a funzionare.",
"withdraw": "Revoca il consenso",
"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.",
"confirmBody": "Salvo che un altro consenso li copra ancora, si fermano subito: il Coach e Insights quando usano l'accesso IA fornito da questo server, e la lettura dei documenti con un modello online. Con la tua chiave o un modello locale, il Coach e Insights continuano a funzionare. Salvo che un altro consenso copra ancora Insights, 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",
@@ -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": {
+9 -3
View File
@@ -5842,9 +5842,9 @@
"consent": {
"title": "AI 동의",
"activeSince": "{date}에 동의했어요. AI 기능을 위해 건강 데이터가 설정한 제공자에게 전송될 수 있어요.",
"withdrawn": "동의를 철회했어요. 건강 데이터를 AI 제공자에게 보내지 않고, AI 기능도 꺼진 상태로 유지돼요.",
"withdrawn": "동의를 철회했어요. 다른 동의가 적용되지 않는 한, 코치와 인사이트는 더 이상 이 서버가 제공하는 AI 연결을 사용하지 않고 온라인 모델도 문서를 읽지 않아요. 자신의 키나 로컬 모델을 사용하는 코치와 인사이트는 계속 작동해요.",
"withdraw": "동의 철회",
"confirmBody": "AI 기능이 즉시 중지되고 모델이 작성한 메모가 삭제됩니다: 상태 메모, 일일 브리핑, 모델이 작성한 기간 요약, 새 측정값 알림 문장, 운동 메모. 코치 대화, 코치가 기억한 내용, 계획, 문서 요약은 그대로 남으며 직접 읽거나 삭제할 수 있습니다.",
"confirmBody": "다른 동의가 여전히 적용되지 않는 한, 다음이 즉시 중지됩니다: 이 서버가 제공하는 AI 연결을 사용하는 코치와 인사이트, 그리고 온라인 모델을 통한 문서 읽기. 자신의 키나 로컬 모델을 사용하는 코치와 인사이트는 계속 작동합니다. 인사이트에 적용되는 다른 동의가 없으면 모델이 작성한 메모가 삭제됩니다: 상태 메모, 일일 브리핑, 모델이 작성한 기간 요약, 새 측정값 알림 문장, 운동 메모. 코치 대화, 코치가 기억한 내용, 계획, 문서 요약은 그대로 남으며 직접 읽거나 삭제할 수 있습니다.",
"confirmWithdraw": "철회",
"regrantHint": "다시 동의하면 AI 기능이 건강 데이터를 설정한 제공자에게 보낼 수 있어요.",
"grant": "동의하기",
@@ -5867,7 +5867,13 @@
"coachConversations": {
"title": "코치 대화",
"description": "대화를 열어 읽거나 삭제하세요.",
"more": "더 보기"
"more": "더 보기",
"openAll": "모든 대화 열기"
},
"coachPlans": {
"title": "코치 계획",
"description": "코치와 합의한 계획을 읽거나 삭제할 수 있습니다.",
"open": "계획 열기"
}
},
"dashboard": {
+9 -3
View File
@@ -5842,9 +5842,9 @@
"consent": {
"title": "Zgoda na AI",
"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.",
"withdrawn": "Wycofana. Jeśli żadna inna zgoda tego nie obejmuje, Coach i Insights nie korzystają już z dostępu AI zapewnianego przez ten serwer, a żaden model online nie czyta Twoich dokumentów. Na własnym kluczu lub lokalnym modelu Coach i Insights działają dalej.",
"withdraw": "Wycofaj zgodę",
"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ąć.",
"confirmBody": "Jeśli żadna inna Twoja zgoda ich nie obejmuje, od razu przestają działać: Coach i Insights tam, gdzie korzystają z dostępu AI zapewnianego przez ten serwer, oraz czytanie dokumentów przez model online. Na własnym kluczu lub lokalnym modelu Coach i Insights działają dalej. Jeśli żadna inna zgoda nie obejmuje już Insights, 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",
@@ -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": {
@@ -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(<client>, 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);
},
);
});
@@ -39,6 +39,8 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { consentNeededForProviders } from "@/lib/ai/capabilities/resolve";
const MESSAGES = join(__dirname, "../../messages");
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
@@ -417,3 +419,51 @@ describe("the MCP token copy never claims the mint is read-only", () => {
});
}
});
/**
* 5. AI CONSENT WITHDRAWAL. The confirmation said "the AI features stop
* straight away" and the withdrawn line said no health data goes to an AI
* provider any more. Neither holds: the Coach and Insights need the
* receipt only when they use the AI access the server provides (the
* operator's key or connection), so on the person's own key or a local
* model they keep working; and the deletion of model-written notes is
* skipped while another receipt still covers Insights. The copy now names
* both conditions. The behaviour half is pinned against the resolver, so a
* change to who needs the receipt fails here next to the copy it would
* falsify.
*/
describe("AI consent withdrawal copy matches what withdrawing does", () => {
it("the Coach and Insights need no receipt on an own key or a local model", () => {
for (const key of ["coach", "briefing", "statusText"] as const) {
expect(consentNeededForProviders(key, ["openai"])).toBe(false);
expect(consentNeededForProviders(key, ["local"])).toBe(false);
expect(consentNeededForProviders(key, ["admin-openai"])).toBe(true);
}
});
/** Per locale: a phrase naming the own key, and one naming a local model. */
const OWN_PROVIDER: Record<string, readonly [string, string]> = {
en: ["own key", "local model"],
de: ["eigenen schlüssel", "lokalen modell"],
es: ["tu propia clave", "modelo local"],
fr: ["votre propre clé", "modèle local"],
it: ["tua chiave", "modello locale"],
pl: ["własnym kluczu", "lokalnym modelu"],
ko: ["자신의 키", "로컬 모델"],
};
for (const locale of LOCALES) {
it(`${locale} says what keeps working and when the notes stay`, () => {
const b = bundle(locale);
for (const path of [
"settings.ai.consent.confirmBody",
"settings.ai.consent.withdrawn",
]) {
const copy = resolve(b, path).toLowerCase();
for (const phrase of OWN_PROVIDER[locale]) {
expect(copy, `${locale} ${path}`).toContain(phrase);
}
}
});
}
});
+6 -5
View File
@@ -32,7 +32,7 @@ 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 { useCycleRingDial } 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";
@@ -373,9 +373,10 @@ export default function InsightsPageClient() {
"overview:cycle-ring",
user?.modules,
);
const cycleRingDial = useCycleRingHasDial(
isAuthenticated && cycleRingVisible,
);
// Until that read answers the strip keeps its skeleton, so it never paints
// Strain first and swaps it for the ring when the calendar lands.
const cycleRing = useCycleRingDial(isAuthenticated && cycleRingVisible);
const cycleRingDial = cycleRing.dial;
// 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
@@ -467,7 +468,7 @@ export default function InsightsPageClient() {
"wellness-scores": (
<WellnessScores
read={dashboardDerived.read}
isLoading={dashboardDerived.isLoading}
isLoading={dashboardDerived.isLoading || cycleRing.pending}
isError={dashboardDerived.isError}
refetch={dashboardDerived.refetch}
// v1.15.3 — the cycle ring rides the scores strip as a gated sibling
@@ -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) => {
+6 -2
View File
@@ -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) => {
@@ -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"),
@@ -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) => {
@@ -2,14 +2,20 @@
* 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.
*
* The decision waits on the calendar read, so the hook also says when it is
* still pending: the strip holds its skeleton until then instead of painting
* Strain and swapping it for the ring a moment later.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
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";
import { localYmd, useCycleRingDial } from "../use-cycle";
function window(): [string, string] {
const from = new Date();
@@ -20,34 +26,56 @@ function window(): [string, string] {
}
function Probe({ enabled }: { enabled: boolean }) {
return <span>{String(useCycleRingHasDial(enabled))}</span>;
const { dial, pending } = useCycleRingDial(enabled);
return <span>{`${dial}:${pending}`}</span>;
}
function hasDial(verdict: unknown, enabled = true): boolean {
function probe(verdict: unknown | undefined, enabled = true): string {
const client = new QueryClient({
defaultOptions: { queries: { staleTime: Infinity, retry: 0 } },
});
const [from, to] = window();
client.setQueryData(queryKeys.cycleCalendar(from, to), { verdict });
if (verdict !== undefined) {
client.setQueryData(queryKeys.cycleCalendar(from, to), { verdict });
}
const html = renderToStaticMarkup(
<QueryClientProvider client={client}>
<Probe enabled={enabled} />
</QueryClientProvider>,
);
return html === "<span>true</span>";
return html.replace(/<\/?span>/g, "");
}
describe("useCycleRingHasDial", () => {
it("is true while a cycle is active today", () => {
expect(hasDial({ phase: "follicular", dayOfCycle: 6 })).toBe(true);
describe("useCycleRingDial", () => {
it("has a dial while a cycle is active today", () => {
expect(probe({ phase: "follicular", dayOfCycle: 6 })).toBe("true:false");
});
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("has none without an active cycle, so no empty cell is reserved", () => {
expect(probe({ phase: null, dayOfCycle: null })).toBe("false:false");
expect(probe(null)).toBe("false:false");
});
it("is false while the cycle surface is hidden", () => {
expect(hasDial({ phase: "luteal", dayOfCycle: 20 }, false)).toBe(false);
it("has none, and is not pending, while the cycle surface is hidden", () => {
expect(probe({ phase: "luteal", dayOfCycle: 20 }, false)).toBe(
"false:false",
);
expect(probe(undefined, false)).toBe("false:false");
});
it("is pending until the calendar read lands", () => {
expect(probe(undefined)).toBe("false:true");
});
});
describe("the scores strip waits for the ring's decision", () => {
it("holds its skeleton while the decision is pending", () => {
const source = readFileSync(
join(process.cwd(), "src/app/insights/page-client.tsx"),
"utf8",
);
expect(source).toMatch(
/isLoading=\{\s*dashboardDerived\.isLoading\s*\|\|\s*cycleRing\.pending\s*\}/,
);
});
});
+14 -6
View File
@@ -130,15 +130,23 @@ export function useCycleRingCalendar(enabled = true) {
}
/**
* 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.
* Whether the cycle ring would draw a dial today, and whether that is still
* unknown. 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. While the
* calendar read is `pending` the strip holds its skeleton, so it never paints
* Strain and then swaps it for the ring. A failed read settles to no dial.
*/
export function useCycleRingHasDial(enabled: boolean): boolean {
export function useCycleRingDial(enabled: boolean): {
dial: boolean;
pending: boolean;
} {
const calendar = useCycleRingCalendar(enabled);
const verdict = calendar.data?.verdict;
return enabled && verdict?.phase != null && verdict.dayOfCycle != null;
return {
dial: enabled && verdict?.phase != null && verdict.dayOfCycle != null,
pending: enabled && calendar.isPending,
};
}
export function useCycleHistory(limit = 24) {
@@ -103,7 +103,12 @@ describe("recordOptions", () => {
describe("<DocumentRecordLinks>", () => {
it("lets a manager link the page to doses and visits", () => {
const html = render(
<DocumentRecordLinks doc={doc()} canManage onChange={() => undefined} />,
<DocumentRecordLinks
doc={doc()}
canManage
seedFresh
onChange={() => undefined}
/>,
);
expect(html).toContain('data-slot="document-vaccination-links"');
expect(html).toContain('data-slot="document-vaccination-links-add"');
@@ -124,6 +129,7 @@ describe("<DocumentRecordLinks>", () => {
],
})}
canManage={false}
seedFresh
onChange={() => undefined}
/>,
);
@@ -137,9 +143,26 @@ describe("<DocumentRecordLinks>", () => {
<DocumentRecordLinks
doc={doc({ vaccinationLinks: null })}
canManage
seedFresh
onChange={() => 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(
<DocumentRecordLinks
doc={doc()}
canManage
seedFresh={false}
onChange={() => undefined}
/>,
);
expect(html).not.toContain('data-slot="document-vaccination-links-add"');
expect(html).not.toContain('data-slot="document-visit-links-add"');
});
});
@@ -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<InboundDocumentDetailDto>(`/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)}
/>
@@ -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)
@@ -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<LabListPage>(
@@ -103,7 +96,7 @@ export function EncounterLinkPickers({
});
const episodes = useQuery({
queryKey: queryKeys.illnessEpisodes(true),
queryKey: queryKeys.illnessEpisodesPicker(EPISODE_FETCH_LIMIT),
enabled: illnessOn,
queryFn: () =>
apiGet<IllnessEpisodeDTO[]>(
@@ -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(
<I18nProvider initialLocale="en">
<AiSetupHint surface="insights" />
</I18nProvider>,
);
vi.unstubAllGlobals();
const match = html.match(
/<button[^>]*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");
});
});
+3 -1
View File
@@ -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"
>
<X className="size-4" aria-hidden="true" />
</Button>
+7 -1
View File
@@ -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);
},
});
}
@@ -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("<ModulePageGate>", () => {
@@ -98,4 +103,29 @@ describe("<ModulePageGate>", () => {
});
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"');
});
});
+19 -1
View File
@@ -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<string> = [
"/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:<slug>`), so
* `/insights/mood` and `/insights/workouts/<id>` follow their own module while
* `/insights` itself follows none. Every other page answers through the nav
* destination it sits under (`nav:<href>`), most specific first.
* destination it sits under (`nav:<href>`), 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}`);
@@ -0,0 +1,93 @@
/**
* The measurement form offers only types whose module is on.
*
* The add menu's "Measurement" entry (and every other mount of the form)
* listed sleep and glucose with those modules switched off, so a person could
* file a reading into a domain whose pages, charts and reads are all hidden,
* or be refused by the route. The form now filters its types through the
* surface map's type ownership (`summary:<type>`), which the test below holds
* equal to the measurement-scope ownership the server gates on.
*/
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import type { MeasurementType } from "@/generated/prisma/client";
import { moduleForMeasurementType } from "@/lib/modules/measurement-scope";
import { surfaceModule } from "@/lib/modules/surface";
const auth = vi.hoisted(() => ({
modules: undefined as Record<string, boolean> | undefined,
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
role: "USER",
unitPreference: "metric",
glucoseUnit: null,
modules: auth.modules,
},
isAuthenticated: true,
isLoading: false,
}),
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({
invalidateQueries: vi.fn(),
refetchQueries: vi.fn(),
}),
}));
vi.mock("@/lib/api/api-fetch", () => ({
apiPost: vi.fn().mockResolvedValue({}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import {
MEASUREMENT_FORM_TYPE_VALUES,
MeasurementForm,
measurementFormTypes,
} from "../measurement-form";
describe("measurementFormTypes", () => {
it("drops sleep and glucose while their modules are off", () => {
const values = measurementFormTypes({ sleep: false, glucose: false }).map(
(t) => t.value,
);
expect(values).not.toContain("SLEEP_DURATION");
expect(values).not.toContain("BLOOD_GLUCOSE");
expect(values).toContain("WEIGHT");
});
it("offers every type while the module map is unknown or all on", () => {
expect(measurementFormTypes(undefined)).toHaveLength(
MEASUREMENT_FORM_TYPE_VALUES.length,
);
expect(measurementFormTypes({ sleep: true, glucose: true })).toHaveLength(
MEASUREMENT_FORM_TYPE_VALUES.length,
);
});
it("reads the same ownership the server gates measurement types on", () => {
let owned = 0;
for (const type of MEASUREMENT_FORM_TYPE_VALUES) {
const owner = moduleForMeasurementType(type as MeasurementType);
if (owner) owned += 1;
expect(surfaceModule(`summary:${type}`), type).toBe(owner ?? undefined);
}
expect(owned).toBeGreaterThan(0);
});
});
describe("<MeasurementForm> with a type's module off", () => {
it("does not open on that type, even when asked to", () => {
auth.modules = { glucose: false };
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MeasurementForm defaultType="BLOOD_GLUCOSE" />
</I18nProvider>,
);
auth.modules = undefined;
expect(html).not.toContain("mg/dL");
expect(html).toContain("mmHg");
});
});
@@ -28,6 +28,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { useAuth } from "@/hooks/use-auth";
import { resolveGlucoseUnit, toCanonicalMgdl } from "@/lib/glucose";
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
import {
entryValueToCanonical,
parseDecimalEntry,
@@ -195,6 +196,20 @@ export const MEASUREMENT_FORM_TYPE_VALUES = MEASUREMENT_TYPES.map(
(t) => t.value,
) as readonly string[];
/**
* The types the form offers for a module map: a type a module owns
* (`summary:<type>` in the surface map, the same ownership the server gates
* measurement types on) is left out while that module is off. An unknown map
* (the account still loading) offers every type.
*/
export function measurementFormTypes(
modules: SurfaceModuleMap | null | undefined,
): ReadonlyArray<(typeof MEASUREMENT_TYPES)[number]> {
return MEASUREMENT_TYPES.filter((mt) =>
isSurfaceVisible(`summary:${mt.value}`, modules),
);
}
// Legacy / Insights-internal tokens that predate the canonical enum.
// Older empty-state CTAs and a handful of dashboard tiles still emit
// these — translate them to the form's canonical value so the link
@@ -307,12 +322,18 @@ export function MeasurementForm({
// seed from the last type the user actually saved rather than always
// landing on BLOOD_PRESSURE. Lazy initializer so the localStorage read
// happens once, at mount, not on every render.
const [type, setType] = useState(
const [selectedType, setType] = useState(
() =>
normalizedDefault ||
getLastUsedMeasurementType(MEASUREMENT_FORM_TYPE_VALUES) ||
"BLOOD_PRESSURE",
);
// Only types whose module is on. A default, a deep link or the last-used
// type that is switched off falls back to the first offered type.
const offeredTypes = measurementFormTypes(user?.modules);
const type = offeredTypes.some((mt) => mt.value === selectedType)
? selectedType
: (offeredTypes[0]?.value ?? "BLOOD_PRESSURE");
const [value, setValue] = useState("");
const [sysBp, setSysBp] = useState("");
const [diaBp, setDiaBp] = useState("");
@@ -515,7 +536,7 @@ export function MeasurementForm({
<SelectValue />
</SelectTrigger>
<SelectContent>
{MEASUREMENT_TYPES.map((mt) => (
{offeredTypes.map((mt) => (
<SelectItem key={mt.value} value={mt.value}>
{t(mt.labelKey)}
</SelectItem>
@@ -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/);
});
});
+6 -4
View File
@@ -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"),
});
+8 -3
View File
@@ -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;
+55 -2
View File
@@ -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<SpotlightRect | null>(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));
}, []);
@@ -36,6 +36,7 @@ import { QueryErrorRow } from "@/components/ui/query-error-row";
import { SettingsInfoTile } from "@/components/settings/_info-tile";
import { Label } from "@/components/ui/label";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { useAiCapability } from "@/hooks/use-ai-capability";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
@@ -61,6 +62,7 @@ const FIELD_CLASSES =
export function AboutMeNoteManager() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const coach = useAiCapability("coach");
const [draft, setDraft] = useState<string | null>(null);
const query = useQuery({
@@ -194,14 +196,18 @@ export function AboutMeNoteManager() {
<li key={q}>{q}</li>
))}
</ul>
<p className="mt-3 text-xs">
<Link
href="/coach"
className="text-primary underline-offset-4 hover:underline"
>
{t("settings.ai.aboutMe.questionsOpenCoach")}
</Link>
</p>
{/* Only while the Coach can open; without it the link would
bounce back to Insights. The questions stay readable. */}
{coach.available ? (
<p className="mt-3 text-xs">
<Link
href="/coach"
className="text-primary underline-offset-4 hover:underline"
>
{t("settings.ai.aboutMe.questionsOpenCoach")}
</Link>
</p>
) : null}
</SettingsInfoTile>
)}
@@ -0,0 +1,80 @@
/**
* A link to `/coach` while the Coach is unavailable (no provider, no
* consent) only bounces: the Coach page sends the visitor back to Insights.
* Settings → Coach's sources pointer and the About-me questions tile offer
* the link only while the Coach can actually open.
*/
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
const coachState = vi.hoisted(() => ({ available: true }));
vi.mock("@/hooks/use-ai-capability", () => ({
useAiCapability: () => ({
available: coachState.available,
reason: coachState.available ? null : "no_provider",
onDeviceAllowed: false,
}),
}));
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 { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs";
import { CoachPrefsSection } from "../coach-prefs-section";
import { AboutMeNoteManager } from "@/components/records/about-me-note-manager";
function render(node: React.ReactNode): string {
const client = new QueryClient({
defaultOptions: { queries: { retry: 0, staleTime: Infinity } },
});
client.setQueryData(queryKeys.coachPrefs(), DEFAULT_COACH_PREFS);
client.setQueryData(queryKeys.coachAboutMe(), {
aboutMe: null,
conditions: null,
allergies: null,
coachFocus: null,
pendingQuestions: ["How do you sleep?"],
updatedAt: "2026-09-01T00:00:00.000Z",
maxChars: 4000,
fieldMaxChars: 500,
});
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
beforeEach(() => {
coachState.available = true;
});
describe("Coach links follow the Coach's capability", () => {
it("the sources pointer links to the Coach while it is available", () => {
expect(render(<CoachPrefsSection isAuthenticated />)).toContain(
'href="/coach"',
);
});
it("the sources pointer offers no dead link without the Coach", () => {
coachState.available = false;
const html = render(<CoachPrefsSection isAuthenticated />);
expect(html).not.toContain('href="/coach"');
expect(html).not.toContain("coach-prefs-sources-pointer");
});
it("the About-me questions link to the Coach while it is available", () => {
expect(render(<AboutMeNoteManager />)).toContain('href="/coach"');
});
it("the About-me questions stay readable but lose the link without the Coach", () => {
coachState.available = false;
const html = render(<AboutMeNoteManager />);
expect(html).toContain("How do you sleep?");
expect(html).not.toContain('href="/coach"');
});
});
@@ -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(
<QueryClientProvider client={client(stored)}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
@@ -142,3 +156,43 @@ describe("<StoredCoachMemory> (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(<StoredCoachMemory isAuthenticated />, {
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(<CoachSection />, {
facts: [],
conversations: [],
plans: [PLAN],
});
expect(html).toContain('href="/coach/plans"');
});
it("shows no plans card without plans", () => {
const html = render(<StoredCoachMemory isAuthenticated />, {
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(<StoredCoachMemory isAuthenticated />, {
facts: [],
conversations: [CONVERSATION],
});
expect(html).toContain('data-slot="settings-coach-conversations-open-all"');
expect(html).toContain('href="/coach/conversations"');
});
});
+8 -10
View File
@@ -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);
},
});
@@ -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
@@ -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);
@@ -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);
},
});
@@ -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");
@@ -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);
@@ -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
* (`<LayoutCoachFab>`, `<LayoutCoachMount>`, the inline `<CoachLaunchButton>`
* 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();
},
@@ -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);
@@ -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);
@@ -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);
@@ -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);
@@ -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({
))}
</ul>
)}
{history.hasNextPage ? (
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 self-start sm:min-h-9"
disabled={history.isFetchingNextPage}
onClick={() => void history.fetchNextPage()}
>
{t("settings.ai.coachConversations.more")}
</Button>
) : null}
<SettingsCardActions align="start">
{history.hasNextPage ? (
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={history.isFetchingNextPage}
onClick={() => void history.fetchNextPage()}
>
{t("settings.ai.coachConversations.more")}
</Button>
) : null}
{visible.length > 0 ? (
<Button
asChild
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
>
<Link
href="/coach/conversations"
data-slot="settings-coach-conversations-open-all"
>
{t("settings.ai.coachConversations.openAll")}
</Link>
</Button>
) : null}
</SettingsCardActions>
</SettingsCard>
);
}
@@ -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
/>
<CoachPlansMemoryCard isAuthenticated={isAuthenticated} />
<CoachRemindersSection isAuthenticated={isAuthenticated} hideWhenEmpty />
</>
);
@@ -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 (
<SettingsCard
as="section"
aria-labelledby="settings-coach-plans-title"
data-testid="settings-coach-plans-card"
>
<SettingsCardHeader
icon={Target}
titleId="settings-coach-plans-title"
title={t("settings.ai.coachPlans.title")}
description={t("settings.ai.coachPlans.description")}
/>
<SettingsCardActions align="start">
<Button
asChild
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
>
<Link href="/coach/plans" data-slot="settings-coach-plans-open">
{t("settings.ai.coachPlans.open")}
</Link>
</Button>
</SettingsCardActions>
</SettingsCard>
);
}
+17 -12
View File
@@ -39,6 +39,7 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs";
import { useAiCapability } from "@/hooks/use-ai-capability";
import {
DEFAULT_COACH_PREFS,
type CoachExcludeMetric,
@@ -74,6 +75,7 @@ export interface CoachPrefsSectionProps {
export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
const { t } = useTranslations();
const coach = useAiCapability("coach");
const { data: persisted } = useCoachPrefs({ enabled: isAuthenticated });
@@ -225,19 +227,22 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
used to render its own copies of both controls; two writable
surfaces for the same persisted fields is exactly the
redundancy the rail was built to end. A pointer replaces
them. */}
<p
data-slot="coach-prefs-sources-pointer"
className="text-muted-foreground border-border/60 rounded-md border border-dashed px-3 py-2 text-xs leading-relaxed"
>
{t("insights.coach.settingsSourcesPointer")}{" "}
<Link
href="/coach"
className="text-foreground underline underline-offset-2"
them. Only while the Coach can open: without it the link
would bounce back to Insights and the rail is out of reach. */}
{coach.available ? (
<p
data-slot="coach-prefs-sources-pointer"
className="text-muted-foreground border-border/60 rounded-md border border-dashed px-3 py-2 text-xs leading-relaxed"
>
{t("insights.coach.settingsSourcesPointerLink")}
</Link>
</p>
{t("insights.coach.settingsSourcesPointer")}{" "}
<Link
href="/coach"
className="text-foreground underline underline-offset-2"
>
{t("insights.coach.settingsSourcesPointerLink")}
</Link>
</p>
) : null}
{/* Exclude metrics — checked = excluded. */}
<div className="flex flex-col gap-2">
@@ -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() {
<CoachConversationsMemoryCard isAuthenticated={authed} />
<CoachPlansMemoryCard isAuthenticated={authed} />
<CoachRemindersSection
isAuthenticated={authed}
hideWhenEmpty={!coachEnabled}
@@ -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);
},
});
+6 -2
View File
@@ -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")),
+7 -2
View File
@@ -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(),
@@ -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"),
});
@@ -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");
});
});
+43
View File
@@ -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;
@@ -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());
});
});
@@ -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),
);
});
});
+4
View File
@@ -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,
+31
View File
@@ -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,33 @@ 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(),
];
/**
* 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(),
];
/**
+6
View File
@@ -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