diff --git a/e2e/onboarding-ai-offer.spec.ts b/e2e/onboarding-ai-offer.spec.ts deleted file mode 100644 index 7f09c78e8..000000000 --- a/e2e/onboarding-ai-offer.spec.ts +++ /dev/null @@ -1,220 +0,0 @@ -/** - * v1.38.19 — the AI offer at the end of the setup flow. - * - * The screen used to promise that insights "work for you right now" the - * moment the operator had configured a key. It never knew whether the key - * answered, and it never mentioned the consent receipt the egress actually - * needs — so the person who believed it met a `consent.ai.required` refusal - * on their first briefing. On 2026-09-11 the operator's own instance was - * answering HTTP 500 from its OAuth proxy while the screen kept promising. - * - * Three runs, one account, and the only difference between them is a row in - * `provider_health`: - * - * a success inside the freshness window → the offer, and one tap grants - * a dead credential inside its cooldown → "not answering", and no button - * no row at all → neither: the panel says nothing - * - * What is deliberately absent: any call to a provider. The flow's rule holds - * — nothing in it talks to one — and a probe here would bill the operator for - * every registration on the instance. - * - * Serial, and it restores the instance state it seeded: the tri-state folds - * across accounts by design, so its inputs are instance-wide. - */ -import { - E2E_SETUP_AI_OFFER, - SETUP_AI_OFFER_STORAGE_STATE_PATH, -} from "./setup/global-setup"; -import { - clearAiConsent, - clearOperatorProvider, - clearSharedProviderResult, - seedOperatorProvider, - seedSharedProviderResult, -} from "./setup/ai-offer-fixture"; -import { resetSetupFlow } from "./setup/setup-flow-fixture"; -import { expect, test } from "./setup/test"; -import { - answer, - answerEverything, - complete, - expectScreen, -} from "./setup-flow-helpers"; - -const OFFER = '[data-slot="onboarding-ai-offer"]'; -const GRANT = '[data-slot="onboarding-ai-offer-grant"]'; -const UNAVAILABLE = '[data-slot="onboarding-ai-unavailable"]'; -const SHARED_KEY = '[data-slot="onboarding-ai-shared-key"]'; -/** The panel once it knows which variant it is; absent while the status is in - * flight, so an assertion of absence cannot pass on an unanswered question. */ -const SETTLED = '[data-slot="onboarding-ai-panel"][data-ai-state]'; - -/** Walk the account to the done screen through the API, then open it. */ -async function openDoneScreen(page: import("@playwright/test").Page) { - await page.goto("/onboarding"); - await answerEverything(page); - // Q6 applies because the answers name glucose and weight; the flow does not - // complete while a screen it owes is unanswered. - await answer(page, { - step: "units", - units: { glucoseUnit: "mmol/L", unitPreference: "metric" }, - }); - await complete(page); - await answer(page, { step: "first-result", status: "skipped" }); - await page.goto("/onboarding/done"); - await expectScreen(page, "done"); - // The panel itself is unconditional; the variant inside it is not. - await expect(page.locator('[data-slot="onboarding-ai-panel"]')).toBeVisible(); -} - -// One account, one instance-wide ledger. Two workers driving this at once -// would each read the other's seed. -test.describe.configure({ mode: "serial" }); - -test.describe("the setup flow's AI offer follows the shared provider's health", () => { - test.use({ storageState: SETUP_AI_OFFER_STORAGE_STATE_PATH }); - - test.beforeEach(async () => { - await resetSetupFlow(E2E_SETUP_AI_OFFER.username); - await clearAiConsent(E2E_SETUP_AI_OFFER.username); - await seedOperatorProvider(); - }); - - test.afterAll(async () => { - await clearOperatorProvider(); - await clearAiConsent(E2E_SETUP_AI_OFFER.username); - }); - - test("offers one tap when the shared provider worked recently, and the tap leaves a receipt", async ({ - page, - }) => { - test.setTimeout(90_000); - await seedSharedProviderResult(E2E_SETUP_AI_OFFER.username, { - result: "ok", - okMinutesAgo: 30, - }); - - // No receipt before the tap — otherwise the assertion after it proves - // nothing about the button. - const before = await page.request.get( - "/api/consent/ai/latest?kind=ai_full", - ); - expect(before.status()).toBe(200); - expect( - ((await before.json()) as { data: { receipt: unknown } }).data.receipt, - ).toBeNull(); - - await openDoneScreen(page); - - await expect(page.locator(SETTLED)).toHaveAttribute( - "data-ai-state", - "offer", - { timeout: 15_000 }, - ); - await expect(page.locator(OFFER)).toBeVisible(); - await expect(page.locator(UNAVAILABLE)).toHaveCount(0); - - // No provider was called to decide this. The flow reads a projection. - const probes: string[] = []; - page.on("request", (req) => { - const path = new URL(req.url()).pathname; - if (path === "/api/ai/test" || path.startsWith("/api/insights/")) - probes.push(path); - }); - - await page.locator(GRANT).click(); - - // The line the person reads swaps to the granted one. - await expect(page.locator(SHARED_KEY)).toBeVisible({ timeout: 15_000 }); - await expect(page.locator(GRANT)).toHaveCount(0); - expect(probes, "the offer must not trigger a provider call").toEqual([]); - - const after = await page.request.get("/api/consent/ai/latest?kind=ai_full"); - const receipt = ( - (await after.json()) as { - data: { receipt: { kind: string; revokedAt: string | null } | null }; - } - ).data.receipt; - expect(receipt).not.toBeNull(); - expect(receipt!.kind).toBe("ai_full"); - expect(receipt!.revokedAt).toBeNull(); - }); - - test("says the provider is not answering while its credential is benched, and offers no button", async ({ - page, - }) => { - test.setTimeout(90_000); - await seedSharedProviderResult(E2E_SETUP_AI_OFFER.username, { - result: "auth_failed", - }); - - await openDoneScreen(page); - - await expect(page.locator(SETTLED)).toHaveAttribute( - "data-ai-state", - "unavailable", - { timeout: 15_000 }, - ); - await expect(page.locator(UNAVAILABLE)).toBeVisible(); - await expect(page.locator(GRANT)).toHaveCount(0); - await expect(page.locator(OFFER)).toHaveCount(0); - // The unproven promise is not shown to somebody who has not consented. - await expect(page.locator(SHARED_KEY)).toHaveCount(0); - - // The rest of the panel is untouched: the flow stays complete without it. - await expect( - page.locator('[data-slot="onboarding-ai-keyless"]'), - ).toBeVisible(); - await expect( - page.locator('[data-slot="onboarding-open-dashboard"]'), - ).toBeVisible(); - - // The grant route itself stays open — it is the user's own consent act - // and is reachable from Settings at any time. What this case pins is the - // decision the screen was handed, so read it back from the source. - const status = await page.request.get("/api/user/ai-provider"); - const data = ( - (await status.json()) as { - data: { serverProviderHealth: string; serverProviderOffer: boolean }; - } - ).data; - expect(data.serverProviderHealth).toBe("unhealthy"); - expect(data.serverProviderOffer).toBe(false); - }); - - test("claims nothing on an instance whose provider nobody has used yet", async ({ - page, - }) => { - test.setTimeout(90_000); - // The operator has configured a key and nothing has been asked of it — - // the state of every deployment until its first AI call, and so the most - // common way through this screen. The panel may not offer (nothing is - // known to work) and may not report an outage (nothing failed). - await clearSharedProviderResult(); - - await openDoneScreen(page); - - await expect(page.locator(SETTLED)).toHaveAttribute( - "data-ai-state", - "neutral", - { timeout: 15_000 }, - ); - await expect( - page.locator('[data-slot="onboarding-ai-keyless"]'), - ).toBeVisible(); - await expect(page.locator(UNAVAILABLE)).toHaveCount(0); - await expect(page.locator(OFFER)).toHaveCount(0); - await expect(page.locator(GRANT)).toHaveCount(0); - await expect(page.locator(SHARED_KEY)).toHaveCount(0); - - const status = await page.request.get("/api/user/ai-provider"); - const data = ( - (await status.json()) as { - data: { serverProviderHealth: string; serverProviderOffer: boolean }; - } - ).data; - expect(data.serverProviderHealth).toBe("unknown"); - expect(data.serverProviderOffer).toBe(false); - }); -}); diff --git a/e2e/setup/ai-offer-fixture.ts b/e2e/setup/ai-offer-fixture.ts deleted file mode 100644 index 5f427fa94..000000000 --- a/e2e/setup/ai-offer-fixture.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * v1.38.19 — the instance state the shared-provider offer reads. - * - * Two rows decide what the setup flow's last screen may say, and neither has - * a product endpoint that writes them the way this journey needs: - * - * `app_settings.admin_ai_key_encrypted` — presence alone makes the operator - * the origin that would serve a user with no provider of their own - * (`managedBy: "server"`). Nothing decrypts it on this path, so the value - * below is a placeholder ciphertext and not a credential of any kind. - * - * `provider_health` — the ledger the tri-state folds. It is written only as - * a side effect of a real completion, which this suite must never make, so - * the journey states the outcome it wants to reason about instead. - * - * Both are INSTANCE-wide by nature: the projection deliberately folds across - * accounts, because a fresh account's own ledger is empty by construction. - * That is why the spec runs serially and puts both back afterwards. - * - * Seeded through `pg`, the same transport `global-setup.ts` and the other - * fixtures use. - */ -import pg from "pg"; - -/** Not a key. Presence is the only thing read on this path. */ -const PLACEHOLDER_ADMIN_KEY = "e2e-admin-key-presence-only"; - -function connect(): pg.Pool { - const url = process.env.DATABASE_URL; - if (!url) { - throw new Error("[ai-offer-fixture] DATABASE_URL is not set"); - } - return new pg.Pool({ connectionString: url }); -} - -async function userId(pool: pg.Pool, username: string): Promise { - const { rows } = await pool.query<{ id: string }>( - `SELECT id FROM users WHERE username = $1`, - [username], - ); - const id = rows[0]?.id; - if (!id) throw new Error(`[ai-offer-fixture] no account named ${username}`); - return id; -} - -/** The operator has configured a shared provider on this instance. */ -export async function seedOperatorProvider(): Promise { - const pool = connect(); - try { - await pool.query( - `INSERT INTO app_settings (id, admin_ai_key_encrypted) - VALUES ('singleton', $1) - ON CONFLICT (id) DO UPDATE SET admin_ai_key_encrypted = EXCLUDED.admin_ai_key_encrypted`, - [PLACEHOLDER_ADMIN_KEY], - ); - } finally { - await pool.end(); - } -} - -/** Put the instance back: no operator key, no ledger rows for either tag. */ -export async function clearOperatorProvider(): Promise { - const pool = connect(); - try { - await pool.query( - `UPDATE app_settings SET admin_ai_key_encrypted = NULL WHERE id = 'singleton'`, - ); - await pool.query( - `DELETE FROM provider_health - WHERE provider_type IN ('admin-openai', 'admin-codex')`, - ); - } finally { - await pool.end(); - } -} - -/** - * State one outcome for the operator's OpenAI provider. - * - * `ok` — it served somebody `okMinutesAgo` minutes ago. - * `auth_failed` — its credential is dead and benched for another hour, - * which is the shape the operator's own instance was in on - * 2026-09-11 (a 500 from the OAuth proxy, then a 401). - */ -export async function seedSharedProviderResult( - username: string, - outcome: { result: "ok"; okMinutesAgo: number } | { result: "auth_failed" }, -): Promise { - const pool = connect(); - try { - const id = await userId(pool, username); - await pool.query( - `DELETE FROM provider_health - WHERE provider_type IN ('admin-openai', 'admin-codex')`, - ); - if (outcome.result === "ok") { - await pool.query( - `INSERT INTO provider_health - (id, user_id, provider_type, last_result, consecutive_failures, - last_ok_at, updated_at) - VALUES ($1, $2, 'admin-openai', 'ok', 0, - NOW() - ($3 || ' minutes')::interval, NOW())`, - [`e2e-ph-${id}`, id, String(outcome.okMinutesAgo)], - ); - } else { - await pool.query( - `INSERT INTO provider_health - (id, user_id, provider_type, last_result, last_status, - consecutive_failures, last_failure_at, next_retry_at, updated_at) - VALUES ($1, $2, 'admin-openai', 'auth_failed', 401, 1, - NOW(), NOW() + interval '1 hour', NOW())`, - [`e2e-ph-${id}`, id], - ); - } - } finally { - await pool.end(); - } -} - -/** - * The ledger holds nothing for either shared tag, with the operator's key - * still in place: a brand-new instance before its first AI call, which is - * what every deployment looks like until somebody makes one. - */ -export async function clearSharedProviderResult(): Promise { - const pool = connect(); - try { - await pool.query( - `DELETE FROM provider_health - WHERE provider_type IN ('admin-openai', 'admin-codex')`, - ); - } finally { - await pool.end(); - } -} - -/** No receipt on file — the state a fresh account reaches the screen in. */ -export async function clearAiConsent(username: string): Promise { - const pool = connect(); - try { - const id = await userId(pool, username); - await pool.query(`DELETE FROM consent_receipts WHERE user_id = $1`, [id]); - await pool.query(`DELETE FROM rate_limits WHERE key = 'consent:' || $1`, [ - id, - ]); - } finally { - await pool.end(); - } -} diff --git a/messages/de.json b/messages/de.json index 434e5f54d..4884e4c13 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5177,7 +5177,9 @@ "connectCta": "Gerät verbinden", "logCta": "Ersten Wert eintragen", "importCta": "Bestehende Daten importieren", - "modulesCta": "Module auswählen" + "modulesCta": "Module auswählen", + "aiOptional": "KI ist optional. Alles hier funktioniert auch ohne.", + "aiSetupCta": "KI einrichten" }, "welcomeBack": { "title": "Willkommen zurück", @@ -5198,37 +5200,6 @@ "allergiesPlaceholder": "z. B. Penicillin, Laktose", "privacyNote": "Verschlüsselt gespeichert und nur für besseren Kontext genutzt. Medikamente fragen wir hier nicht ab, die kannst du jederzeit auf der Medikamenten-Seite ergänzen." }, - "ai": { - "panelTitle": "So sehen Insights aus", - "panelIntro": "Coach, das tägliche Briefing und die Einschätzungen je Messwert übersetzen deine eigenen Zahlen in Klartext. So sieht das aus, bevor du irgendetwas verbindest.", - "showSample": "Beispiel-Briefing ansehen", - "sampleTitle": "Tägliches Briefing", - "sampleTag": "Beispiel", - "sampleAriaLabel": "Beispielhaftes tägliches Briefing", - "sampleBody": "Gewicht in zwei Wochen um 1,2 kg gesunken. Ruhepuls stabil bei 58 bpm. Blutdruck an 6 von 7 Tagen im Zielbereich; der einzige hohe Wert folgte auf ein spätes, salziges Abendessen.", - "sampleCaption": "Nur ein Beispiel, nicht deine eigenen Daten. So liest sich ein Briefing, sobald Insights aktiv sind.", - "ladderTitle": "Drei Wege, es zu aktivieren", - "ladderLocalTitle": "Lokales Modell: KI-Anfragen bleiben in deinem Netzwerk", - "ladderLocalBody": "Richte HealthLog auf Ollama oder LM Studio auf deinem eigenen Rechner aus. Kostenlos und vollständig privat, der ruhige Standard.", - "ladderByokTitle": "Eigenen Schlüssel verwenden", - "ladderByokBody": "Nutze deinen eigenen OpenAI- oder Anthropic-Schlüssel. Er läuft über dein Konto und deine Abrechnung, und die API-Stufe hält deine Daten standardmäßig aus dem Training heraus.", - "ladderOauthTitle": "Mit einem Abo anmelden", - "ladderOauthBody": "Verbinde ein ChatGPT-Konto: keine Kosten pro Token, aber die Bedingungen der Verbraucherstufe können Inhalte fürs Training nutzen. Deshalb eine bewusste Wahl, nie der Standard für deine Gesundheitsdaten.", - "keylessLine": "HealthLog ist auch ohne KI vollständig nutzbar. Deine Kacheln, Diagramme, Trends und Exporte funktionieren alle. KI ergänzt nur den Klartext obendrauf.", - "setupCta": "Einrichten (etwa eine Minute)", - "sharedKeyNote": "Diese Instanz stellt bereits einen gemeinsamen KI-Anbieter bereit, deshalb funktionieren Insights für dich sofort, keine Einrichtung nötig.", - "offer": { - "title": "Die Zusammenfassung dieser Instanz ist bereit.", - "body": "Der Betreiber stellt einen Anbieter bereit, der zuletzt innerhalb der letzten 24 Stunden funktioniert hat. Mit einem Tipp schaltest du den Klartext ein; deine Daten gehen dann an diesen einen Anbieter, an niemanden sonst.", - "cta": "Klartext einschalten", - "granted": "Eingeschaltet. Die erste Zusammenfassung erscheint auf dem Dashboard.", - "error": "Das Einschalten hat nicht geklappt. Versuch es gleich noch einmal." - }, - "unavailable": { - "title": "Der Anbieter dieser Instanz antwortet gerade nicht.", - "body": "HealthLog funktioniert ohne Klartext vollständig. Du kannst später in den Einstellungen einen eigenen Anbieter hinterlegen." - } - }, "steps": { "who": "Für wen diese Akte ist", "areas": "Worauf du achten willst", diff --git a/messages/en.json b/messages/en.json index c8c1134b7..c3b536723 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5177,7 +5177,9 @@ "connectCta": "Connect a device", "logCta": "Log your first measurement", "importCta": "Import existing history", - "modulesCta": "Choose your modules" + "modulesCta": "Choose your modules", + "aiOptional": "AI is optional. Everything here works without it.", + "aiSetupCta": "Set up AI" }, "welcomeBack": { "title": "Welcome back", @@ -5198,37 +5200,6 @@ "allergiesPlaceholder": "e.g. penicillin, lactose", "privacyNote": "Stored encrypted and used only to give you better context. We don't ask for your medications here. Add those anytime from the Medications page." }, - "ai": { - "panelTitle": "See what insights look like", - "panelIntro": "Coach, the daily briefing and the per-metric reads turn your own numbers into plain language. Here's the shape of it, before you connect anything.", - "showSample": "See a sample briefing", - "sampleTitle": "Daily Briefing", - "sampleTag": "Example", - "sampleAriaLabel": "Example daily briefing", - "sampleBody": "Weight down 1.2 kg over two weeks. Resting pulse steady at 58 bpm. Blood pressure in range on 6 of 7 days; the one high reading followed a late, salty dinner.", - "sampleCaption": "Example only, not your own data. This is what a briefing reads like once insights are on.", - "ladderTitle": "Three ways to turn it on", - "ladderLocalTitle": "Local model: AI requests stay on your network", - "ladderLocalBody": "Point HealthLog at Ollama or LM Studio on your own machine. Free and fully private, the calm default.", - "ladderByokTitle": "Bring your own key", - "ladderByokBody": "Use your own OpenAI or Anthropic key. It runs under your account and billing, and the API tier keeps your data out of training by default.", - "ladderOauthTitle": "Sign in with a subscription", - "ladderOauthBody": "Connect a ChatGPT account. No per-token cost, but the consumer tier's terms may use content for training, so it's a deliberate choice, never the default for your health data.", - "keylessLine": "HealthLog is fully useful without AI. Your tiles, charts, trends and exports all work. AI only adds the plain-language read on top.", - "setupCta": "Set it up (about a minute)", - "sharedKeyNote": "This instance already provides a shared AI provider, so insights work for you right now, no setup needed.", - "offer": { - "title": "This instance can write your summary in plain language.", - "body": "The operator provides a provider that last worked within the past 24 hours. One tap turns the plain-language read on; your data then goes to that one provider and to nobody else.", - "cta": "Turn on plain language", - "granted": "Turned on. The first summary appears on your dashboard.", - "error": "Turning it on did not work. Give it another try in a moment." - }, - "unavailable": { - "title": "This instance’s provider is not answering right now.", - "body": "HealthLog is fully useful without the plain-language read. You can add a provider of your own later under Settings." - } - }, "steps": { "who": "Who this record is for", "areas": "What to keep an eye on", diff --git a/messages/es.json b/messages/es.json index 7ec2ce09c..df9cd4c5d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5177,7 +5177,9 @@ "connectCta": "Conectar un dispositivo", "logCta": "Registra tu primera medición", "importCta": "Importar historial existente", - "modulesCta": "Elige tus módulos" + "modulesCta": "Elige tus módulos", + "aiOptional": "La IA es opcional. Todo funciona también sin ella.", + "aiSetupCta": "Configurar IA" }, "welcomeBack": { "title": "Bienvenido de nuevo", @@ -5198,37 +5200,6 @@ "allergiesPlaceholder": "p. ej. penicilina, lactosa", "privacyNote": "Se almacena cifrado y solo se usa para darte mejor contexto. Aquí no te pedimos tu medicación: puedes añadirla cuando quieras desde la página de Medicación." }, - "ai": { - "panelTitle": "Mira cómo son los insights", - "panelIntro": "Coach, el resumen diario y las lecturas por métrica convierten tus propios números en lenguaje claro. Así se ve, antes de conectar nada.", - "showSample": "Ver un resumen de ejemplo", - "sampleTitle": "Resumen diario", - "sampleTag": "Ejemplo", - "sampleAriaLabel": "Resumen diario de ejemplo", - "sampleBody": "Peso 1,2 kg menos en dos semanas. Pulso en reposo estable en 58 lpm. Tensión dentro del rango 6 de 7 días; la única lectura alta llegó tras una cena tardía y salada.", - "sampleCaption": "Solo un ejemplo, no son tus datos. Así se lee un resumen cuando los insights están activos.", - "ladderTitle": "Tres formas de activarlo", - "ladderLocalTitle": "Modelo local: las solicitudes de IA no salen de tu red", - "ladderLocalBody": "Apunta HealthLog a Ollama o LM Studio en tu propia máquina. Gratis y totalmente privado: la opción tranquila por defecto.", - "ladderByokTitle": "Usa tu propia clave", - "ladderByokBody": "Usa tu propia clave de OpenAI o Anthropic. Funciona con tu cuenta y facturación, y el nivel de API mantiene tus datos fuera del entrenamiento por defecto.", - "ladderOauthTitle": "Inicia sesión con una suscripción", - "ladderOauthBody": "Conecta una cuenta de ChatGPT. Sin coste por token, pero los términos del nivel de consumo pueden usar el contenido para entrenamiento, así que es una elección deliberada, nunca la opción por defecto para tus datos de salud.", - "keylessLine": "HealthLog es plenamente útil sin IA. Tus paneles, gráficos, tendencias y exportaciones funcionan; la IA solo añade la lectura en lenguaje claro por encima.", - "setupCta": "Configúralo (un minuto aprox.)", - "sharedKeyNote": "Esta instancia ya ofrece un proveedor de IA compartido, así que los insights funcionan para ti ahora mismo, sin configuración.", - "offer": { - "title": "Esta instancia ya puede escribir tu resumen en lenguaje claro.", - "body": "El operador ofrece un proveedor que funcionó por última vez en las últimas 24 horas. Con un toque activas el lenguaje claro; tus datos irán entonces a ese único proveedor y a nadie más.", - "cta": "Activar el lenguaje claro", - "granted": "Activado. El primer resumen aparecerá en tu panel.", - "error": "No se pudo activar. Vuelve a intentarlo en un momento." - }, - "unavailable": { - "title": "El proveedor de esta instancia no responde en este momento.", - "body": "HealthLog es plenamente útil sin el lenguaje claro. Más adelante puedes añadir tu propio proveedor en los ajustes." - } - }, "steps": { "who": "Para quién es este registro", "areas": "Qué quieres vigilar", diff --git a/messages/fr.json b/messages/fr.json index 8781bf069..4dc072670 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5177,7 +5177,9 @@ "connectCta": "Connecter un appareil", "logCta": "Enregistrez votre première mesure", "importCta": "Importer un historique existant", - "modulesCta": "Choisir vos modules" + "modulesCta": "Choisir vos modules", + "aiOptional": "L'IA est facultative. Tout fonctionne aussi sans elle.", + "aiSetupCta": "Configurer l'IA" }, "welcomeBack": { "title": "Bon retour", @@ -5198,37 +5200,6 @@ "allergiesPlaceholder": "p. ex. pénicilline, lactose", "privacyNote": "Stocké chiffré et utilisé uniquement pour un meilleur contexte. Nous ne demandons pas vos médicaments ici : ajoutez-les quand vous voulez depuis la page Médicaments." }, - "ai": { - "panelTitle": "Voyez à quoi ressemblent les insights", - "panelIntro": "Coach, le briefing quotidien et les lectures par mesure traduisent vos propres chiffres en langage clair. Voici l'idée, avant de connecter quoi que ce soit.", - "showSample": "Voir un exemple de briefing", - "sampleTitle": "Briefing quotidien", - "sampleTag": "Exemple", - "sampleAriaLabel": "Exemple de briefing quotidien", - "sampleBody": "Poids en baisse de 1,2 kg en deux semaines. Pouls au repos stable à 58 bpm. Tension dans la plage 6 jours sur 7 ; la seule valeur élevée a suivi un dîner tardif et salé.", - "sampleCaption": "Exemple uniquement, pas vos données. Voilà à quoi ressemble un briefing une fois les insights activés.", - "ladderTitle": "Trois façons de l'activer", - "ladderLocalTitle": "Modèle local : les requêtes d’IA restent sur votre réseau", - "ladderLocalBody": "Pointez HealthLog vers Ollama ou LM Studio sur votre propre machine. Gratuit et totalement privé, le choix calme par défaut.", - "ladderByokTitle": "Apportez votre clé", - "ladderByokBody": "Utilisez votre propre clé OpenAI ou Anthropic. Elle fonctionne avec votre compte et votre facturation, et le niveau API exclut vos données de l'entraînement par défaut.", - "ladderOauthTitle": "Connectez-vous avec un abonnement", - "ladderOauthBody": "Reliez un compte ChatGPT, sans coût par jeton, mais les conditions du niveau grand public peuvent utiliser le contenu pour l'entraînement : c'est donc un choix délibéré, jamais l'option par défaut pour vos données de santé.", - "keylessLine": "HealthLog est pleinement utile sans IA. Vos tuiles, graphiques, tendances et exports fonctionnent tous ; l'IA n'ajoute que la lecture en langage clair par-dessus.", - "setupCta": "Configurez-le (environ une minute)", - "sharedKeyNote": "Cette instance fournit déjà un fournisseur d'IA partagé : les insights fonctionnent pour vous dès maintenant, aucune configuration nécessaire.", - "offer": { - "title": "Cette instance peut rédiger votre résumé en langage clair.", - "body": "L’exploitant met à disposition un fournisseur qui a fonctionné au cours des dernières 24 heures. Une seule touche active le langage clair ; vos données partent alors vers ce seul fournisseur et vers personne d’autre.", - "cta": "Activer le langage clair", - "granted": "Activé. Le premier résumé apparaîtra sur votre tableau de bord.", - "error": "L’activation n’a pas abouti. Réessayez dans un instant." - }, - "unavailable": { - "title": "Le fournisseur de cette instance ne répond pas pour le moment.", - "body": "HealthLog reste pleinement utile sans le langage clair. Vous pourrez ajouter votre propre fournisseur plus tard dans les paramètres." - } - }, "steps": { "who": "À qui appartient ce dossier", "areas": "Ce que vous voulez suivre", diff --git a/messages/it.json b/messages/it.json index 96740cc49..076ca052a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5177,7 +5177,9 @@ "connectCta": "Collega un dispositivo", "logCta": "Registra la tua prima misurazione", "importCta": "Importa la cronologia esistente", - "modulesCta": "Scegli i tuoi moduli" + "modulesCta": "Scegli i tuoi moduli", + "aiOptional": "L'IA è facoltativa. Tutto funziona anche senza.", + "aiSetupCta": "Configura IA" }, "welcomeBack": { "title": "Bentornato", @@ -5198,37 +5200,6 @@ "allergiesPlaceholder": "es. penicillina, lattosio", "privacyNote": "Memorizzato cifrato e usato solo per darti un contesto migliore. Qui non chiediamo i tuoi farmaci: aggiungili quando vuoi dalla pagina Farmaci." }, - "ai": { - "panelTitle": "Guarda come sono gli insight", - "panelIntro": "Coach, il briefing quotidiano e le letture per metrica traducono i tuoi numeri in linguaggio chiaro. Ecco com'è, prima di collegare qualsiasi cosa.", - "showSample": "Vedi un briefing di esempio", - "sampleTitle": "Briefing quotidiano", - "sampleTag": "Esempio", - "sampleAriaLabel": "Briefing quotidiano di esempio", - "sampleBody": "Peso in calo di 1,2 kg in due settimane. Battito a riposo stabile a 58 bpm. Pressione nel range in 6 giorni su 7: l'unico valore alto è arrivato dopo una cena tardiva e salata.", - "sampleCaption": "Solo un esempio, non i tuoi dati. Ecco come si legge un briefing quando gli insight sono attivi.", - "ladderTitle": "Tre modi per attivarlo", - "ladderLocalTitle": "Modello locale: le richieste all’IA restano nella tua rete", - "ladderLocalBody": "Punta HealthLog su Ollama o LM Studio sulla tua macchina. Gratis e del tutto privato: la scelta tranquilla di default.", - "ladderByokTitle": "Usa la tua chiave", - "ladderByokBody": "Usa la tua chiave OpenAI o Anthropic. Gira sul tuo account e sulla tua fatturazione, e il piano API tiene i tuoi dati fuori dall'addestramento in modo predefinito.", - "ladderOauthTitle": "Accedi con un abbonamento", - "ladderOauthBody": "Collega un account ChatGPT. Nessun costo per token, ma i termini del piano consumer possono usare i contenuti per l'addestramento, quindi è una scelta deliberata, mai l'impostazione predefinita per i tuoi dati sanitari.", - "keylessLine": "HealthLog è pienamente utile anche senza IA. Le tue schede, i grafici, i trend e le esportazioni funzionano tutti; l'IA aggiunge solo la lettura in linguaggio chiaro.", - "setupCta": "Configuralo (circa un minuto)", - "sharedKeyNote": "Questa istanza fornisce già un provider IA condiviso, quindi gli insight funzionano per te fin da subito, senza alcuna configurazione.", - "offer": { - "title": "Questa istanza può già scrivere il tuo riepilogo in parole semplici.", - "body": "Il gestore mette a disposizione un fornitore che ha funzionato nelle ultime 24 ore. Con un tocco attivi le parole semplici; i tuoi dati andranno allora a quell’unico fornitore e a nessun altro.", - "cta": "Attiva le parole semplici", - "granted": "Attivato. Il primo riepilogo comparirà sulla tua dashboard.", - "error": "L’attivazione non è riuscita. Riprova tra un momento." - }, - "unavailable": { - "title": "Il fornitore di questa istanza al momento non risponde.", - "body": "HealthLog resta pienamente utile senza le parole semplici. Potrai aggiungere un fornitore tuo più avanti nelle impostazioni." - } - }, "steps": { "who": "A chi appartiene questa cartella", "areas": "Cosa vuoi tenere d'occhio", diff --git a/messages/ko.json b/messages/ko.json index 7958cec1c..d20994b54 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5177,7 +5177,9 @@ "connectCta": "기기 연결", "logCta": "첫 측정값 기록하기", "importCta": "기존 기록 불러오기", - "modulesCta": "모듈 고르기" + "modulesCta": "모듈 고르기", + "aiOptional": "AI는 선택 사항이에요. AI 없이도 모든 기능이 작동해요.", + "aiSetupCta": "AI 설정" }, "welcomeBack": { "title": "다시 오셨네요", @@ -5198,37 +5200,6 @@ "allergiesPlaceholder": "예: 페니실린, 유당", "privacyNote": "암호화해 저장하고, 더 나은 맥락을 주는 데만 써요. 여기서 약은 묻지 않아요. 복약 페이지에서 언제든 추가하면 돼요." }, - "ai": { - "panelTitle": "인사이트가 어떤 모습인지 보기", - "panelIntro": "코치, 데일리 브리핑, 지표별 해설이 내 숫자를 쉬운 말로 풀어 줘요. 무언가 연결하기 전에, 대략 이런 모습이에요.", - "showSample": "샘플 브리핑 보기", - "sampleTitle": "데일리 브리핑", - "sampleTag": "예시", - "sampleAriaLabel": "데일리 브리핑 예시", - "sampleBody": "2주 동안 체중 1.2kg 감소. 안정 시 맥박은 58bpm으로 꾸준해요. 혈압은 7일 중 6일 범위 안, 높았던 하루는 늦은 시간의 짠 저녁 식사 다음이었어요.", - "sampleCaption": "예시일 뿐 내 데이터가 아니에요. 인사이트를 켜면 브리핑이 이런 식으로 읽혀요.", - "ladderTitle": "켜는 방법 세 가지", - "ladderLocalTitle": "로컬 모델: AI 요청이 내 네트워크 안에 머물러요", - "ladderLocalBody": "내 컴퓨터의 Ollama나 LM Studio를 HealthLog에 연결해요. 무료이고 완전히 비공개라서 가장 마음 편한 기본값이에요.", - "ladderByokTitle": "내 API 키 사용", - "ladderByokBody": "본인의 OpenAI나 Anthropic 키를 쓰면 돼요. 내 계정과 결제로 돌아가고, API 등급은 기본적으로 데이터를 학습에 쓰지 않아요.", - "ladderOauthTitle": "구독 계정으로 로그인", - "ladderOauthBody": "ChatGPT 계정을 연결해요. 토큰당 비용은 없지만 소비자 등급 약관은 내용을 학습에 쓸 수 있어서, 건강 데이터에는 기본값이 아니라 신중히 고를 선택이에요.", - "keylessLine": "AI 없이도 HealthLog는 충분히 쓸모 있어요. 타일, 차트, 추이, 내보내기 모두 그대로 작동하고, AI는 그 위에 쉬운 말 해설을 더할 뿐이에요.", - "setupCta": "설정하기 (1분 정도)", - "sharedKeyNote": "이 인스턴스에는 이미 공용 AI 제공자가 있어서 지금 바로 인사이트를 쓸 수 있어요. 따로 설정할 게 없어요.", - "offer": { - "title": "이 인스턴스가 요약을 쉬운 말로 써 줄 수 있습니다.", - "body": "운영자가 제공하는 공급자가 지난 24시간 안에 마지막으로 정상 동작했습니다. 한 번만 누르면 쉬운 말 요약이 켜지며, 그러면 데이터는 그 하나의 공급자에게만 전달되고 다른 어느 곳에도 가지 않습니다.", - "cta": "쉬운 말 요약 켜기", - "granted": "켜졌습니다. 첫 요약은 대시보드에 표시됩니다.", - "error": "켜지지 않았습니다. 잠시 후에 다시 시도해 주세요." - }, - "unavailable": { - "title": "이 인스턴스의 공급자가 지금은 응답하지 않습니다.", - "body": "쉬운 말 요약 없이도 HealthLog는 완전히 쓸모가 있습니다. 나중에 설정에서 직접 공급자를 등록할 수 있습니다." - } - }, "steps": { "who": "이 기록의 대상", "areas": "관심 있게 볼 항목", diff --git a/messages/nl.json b/messages/nl.json index 3e8edd7bc..258671b35 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -5177,7 +5177,9 @@ "connectCta": "Een apparaat koppelen", "logCta": "Je eerste meting vastleggen", "importCta": "Bestaande geschiedenis importeren", - "modulesCta": "Je modules kiezen" + "modulesCta": "Je modules kiezen", + "aiOptional": "AI is optioneel. Alles werkt ook zonder.", + "aiSetupCta": "AI instellen" }, "welcomeBack": { "title": "Welkom terug", @@ -5198,37 +5200,6 @@ "allergiesPlaceholder": "bijv. penicilline, lactose", "privacyNote": "Versleuteld opgeslagen en alleen gebruikt om je betere context te geven. We vragen hier niet naar je medicatie. Die kun je altijd toevoegen via de pagina Medicatie." }, - "ai": { - "panelTitle": "Bekijk hoe inzichten eruitzien", - "panelIntro": "Coach, de dagelijkse briefing en de toelichting per meetwaarde zetten je eigen cijfers om in begrijpelijke taal. Zo ziet dat eruit, nog voordat je iets koppelt.", - "showSample": "Voorbeeldbriefing bekijken", - "sampleTitle": "Dagelijkse briefing", - "sampleTag": "Voorbeeld", - "sampleAriaLabel": "Voorbeeld van een dagelijkse briefing", - "sampleBody": "Gewicht in twee weken 1,2 kg gedaald. Rusthartslag stabiel op 58 bpm. Bloeddruk binnen het bereik op 6 van de 7 dagen; de ene hoge meting volgde op een laat, zout avondeten.", - "sampleCaption": "Alleen een voorbeeld, niet je eigen gegevens. Zo leest een briefing zodra inzichten aanstaan.", - "ladderTitle": "Drie manieren om het aan te zetten", - "ladderLocalTitle": "Lokaal model: AI-verzoeken blijven op je eigen netwerk", - "ladderLocalBody": "Verwijs HealthLog naar Ollama of LM Studio op je eigen computer. Gratis en volledig privé, de rustige standaardkeuze.", - "ladderByokTitle": "Je eigen sleutel gebruiken", - "ladderByokBody": "Gebruik je eigen OpenAI- of Anthropic-sleutel. Die draait onder je eigen account en facturering, en via de API worden je gegevens standaard niet gebruikt voor training.", - "ladderOauthTitle": "Inloggen met een abonnement", - "ladderOauthBody": "Koppel een ChatGPT-account. Geen kosten per token, maar volgens de voorwaarden voor consumenten mag inhoud voor training worden gebruikt. Het is dus een bewuste keuze, nooit de standaard voor je gezondheidsgegevens.", - "keylessLine": "HealthLog is ook zonder AI volledig bruikbaar. Je tegels, grafieken, trends en exports werken allemaal. AI voegt alleen de toelichting in begrijpelijke taal toe.", - "setupCta": "Instellen (ongeveer een minuut)", - "sharedKeyNote": "Deze instantie biedt al een gedeelde AI-provider, dus inzichten werken nu al voor je, zonder instellen.", - "offer": { - "title": "Deze instantie kan je samenvatting in begrijpelijke taal schrijven.", - "body": "De beheerder stelt een provider beschikbaar die in de afgelopen 24 uur nog werkte. Met één tik zet je de toelichting in begrijpelijke taal aan; je gegevens gaan dan naar die ene provider en naar niemand anders.", - "cta": "Begrijpelijke taal aanzetten", - "granted": "Aangezet. De eerste samenvatting verschijnt op je dashboard.", - "error": "Aanzetten is niet gelukt. Probeer het zo meteen nog eens." - }, - "unavailable": { - "title": "De provider van deze instantie reageert op dit moment niet.", - "body": "HealthLog is ook zonder de toelichting in begrijpelijke taal volledig bruikbaar. Je kunt later onder Instellingen een eigen provider toevoegen." - } - }, "steps": { "who": "Voor wie dit dossier is", "areas": "Wat je in de gaten wilt houden", diff --git a/messages/pl.json b/messages/pl.json index 8eb53e7d2..a9a05ed41 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5177,7 +5177,9 @@ "connectCta": "Połącz urządzenie", "logCta": "Zapisz pierwszy pomiar", "importCta": "Importuj istniejącą historię", - "modulesCta": "Wybierz swoje moduły" + "modulesCta": "Wybierz swoje moduły", + "aiOptional": "AI jest opcjonalne. Wszystko działa także bez niego.", + "aiSetupCta": "Skonfiguruj AI" }, "welcomeBack": { "title": "Witaj ponownie", @@ -5198,37 +5200,6 @@ "allergiesPlaceholder": "np. penicylina, laktoza", "privacyNote": "Przechowywane w postaci zaszyfrowanej i używane tylko po to, by dać Ci lepszy kontekst. Nie pytamy tu o leki, dodasz je w dowolnej chwili na stronie Leki." }, - "ai": { - "panelTitle": "Zobacz, jak wyglądają insighty", - "panelIntro": "Coach, codzienny briefing i oceny dla poszczególnych metryk zamieniają Twoje własne liczby w prosty język. Oto jak to wygląda, zanim cokolwiek podłączysz.", - "showSample": "Zobacz przykładowy briefing", - "sampleTitle": "Codzienny briefing", - "sampleTag": "Przykład", - "sampleAriaLabel": "Przykładowy codzienny briefing", - "sampleBody": "Waga niższa o 1,2 kg w ciągu dwóch tygodni. Tętno spoczynkowe stabilne na 58 bpm. Ciśnienie w normie przez 6 z 7 dni; jedyny wysoki odczyt nastąpił po późnej, słonej kolacji.", - "sampleCaption": "Tylko przykład, to nie są Twoje dane. Tak czyta się briefing, gdy insighty są włączone.", - "ladderTitle": "Trzy sposoby, by to włączyć", - "ladderLocalTitle": "Model lokalny: zapytania do AI zostają w Twojej sieci", - "ladderLocalBody": "Skieruj HealthLog na Ollama lub LM Studio na własnej maszynie. Za darmo i w pełni prywatnie: spokojny wybór domyślny.", - "ladderByokTitle": "Użyj własnego klucza", - "ladderByokBody": "Użyj własnego klucza OpenAI lub Anthropic. Działa na Twoim koncie i rozliczeniu, a poziom API domyślnie wyklucza Twoje dane z treningu.", - "ladderOauthTitle": "Zaloguj się przez subskrypcję", - "ladderOauthBody": "Podłącz konto ChatGPT. Bez kosztu za token, ale warunki poziomu konsumenckiego mogą wykorzystywać treści do treningu, więc to świadomy wybór, nigdy domyślny dla danych o zdrowiu.", - "keylessLine": "HealthLog jest w pełni użyteczny bez AI. Twoje kafelki, wykresy, trendy i eksporty działają; AI dokłada tylko odczyt prostym językiem.", - "setupCta": "Skonfiguruj (około minuty)", - "sharedKeyNote": "Ta instancja udostępnia już wspólnego dostawcę AI, więc insighty działają dla Ciebie od razu, bez konfiguracji.", - "offer": { - "title": "Ta instancja potrafi już napisać twoje podsumowanie zwykłym językiem.", - "body": "Operator udostępnia dostawcę, który działał po raz ostatni w ciągu ostatnich 24 godzin. Jedno dotknięcie włącza zwykły język; twoje dane trafią wtedy do tego jednego dostawcy i do nikogo więcej.", - "cta": "Włącz zwykły język", - "granted": "Włączone. Pierwsze podsumowanie pojawi się na pulpicie.", - "error": "Włączenie się nie powiodło. Spróbuj ponownie za chwilę." - }, - "unavailable": { - "title": "Dostawca tej instancji w tej chwili nie odpowiada.", - "body": "HealthLog jest w pełni użyteczny bez zwykłego języka. Później możesz dodać w ustawieniach własnego dostawcę." - } - }, "steps": { "who": "Dla kogo jest ta kartoteka", "areas": "Co chcesz obserwować", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 2c1560901..046330b66 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -5177,7 +5177,9 @@ "connectCta": "Conectar um dispositivo", "logCta": "Registrar sua primeira medição", "importCta": "Importar histórico existente", - "modulesCta": "Escolher seus módulos" + "modulesCta": "Escolher seus módulos", + "aiOptional": "A IA é opcional. Tudo funciona também sem ela.", + "aiSetupCta": "Configurar IA" }, "welcomeBack": { "title": "Bem-vindo de volta", @@ -5198,37 +5200,6 @@ "allergiesPlaceholder": "ex.: penicilina, lactose", "privacyNote": "Armazenado criptografado e usado apenas para dar melhor contexto. Não pedimos seus medicamentos aqui. Adicione-os a qualquer momento na página Medicamentos." }, - "ai": { - "panelTitle": "Veja como os insights ficam", - "panelIntro": "Coach, o briefing diário e as leituras por métrica transformam seus próprios números em linguagem simples. Aqui está a forma disso, antes de conectar qualquer coisa.", - "showSample": "Ver um briefing de exemplo", - "sampleTitle": "Briefing Diário", - "sampleTag": "Exemplo", - "sampleAriaLabel": "Exemplo de briefing diário", - "sampleBody": "Peso caiu 1,2 kg em duas semanas. Pulso em repouso estável em 58 bpm. Pressão arterial na faixa em 6 de 7 dias; a única leitura alta seguiu um jantar tardio e salgado.", - "sampleCaption": "Apenas exemplo, não seus próprios dados. É assim que um briefing lê quando os insights estão ligados.", - "ladderTitle": "Três formas de ligar", - "ladderLocalTitle": "Modelo local: solicitações de IA permanecem na sua rede", - "ladderLocalBody": "Aponte o HealthLog para Ollama ou LM Studio na sua própria máquina. Gratuito e totalmente privado, o padrão tranquilo.", - "ladderByokTitle": "Traga sua própria chave", - "ladderByokBody": "Use sua própria chave OpenAI ou Anthropic. Roda sob sua conta e cobrança, e o nível de API mantém seus dados fora de treinamento por padrão.", - "ladderOauthTitle": "Entre com uma assinatura", - "ladderOauthBody": "Conecte uma conta ChatGPT. Sem custo por token, mas os termos do nível consumidor podem usar conteúdo para treinamento, então é uma escolha deliberada, nunca o padrão para seus dados de saúde.", - "keylessLine": "O HealthLog é totalmente útil sem IA. Seus blocos, gráficos, tendências e exportações funcionam. A IA só adiciona a leitura em linguagem simples por cima.", - "setupCta": "Configurar (cerca de um minuto)", - "sharedKeyNote": "Esta instância já fornece um provedor de IA compartilhado, então os insights funcionam para você agora, sem configuração.", - "offer": { - "title": "Esta instância pode escrever seu resumo em linguagem simples.", - "body": "O operador fornece um provedor que funcionou pela última vez nas últimas 24 horas. Um toque liga a leitura em linguagem simples; seus dados vão para esse único provedor e para ninguém mais.", - "cta": "Ligar linguagem simples", - "granted": "Ligado. O primeiro resumo aparece no seu painel.", - "error": "Ligar não funcionou. Tente novamente em instantes." - }, - "unavailable": { - "title": "O provedor desta instância não está respondendo agora.", - "body": "O HealthLog é totalmente útil sem a leitura em linguagem simples. Você pode adicionar um provedor próprio mais tarde nas Configurações." - } - }, "steps": { "who": "Para quem é este registro", "areas": "Em que prestar atenção", diff --git a/messages/ru.json b/messages/ru.json index 1b45efb2b..7706cb7c9 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -5177,7 +5177,9 @@ "connectCta": "Подключить устройство", "logCta": "Записать первое измерение", "importCta": "Импортировать существующую историю", - "modulesCta": "Выбрать модули" + "modulesCta": "Выбрать модули", + "aiOptional": "ИИ необязателен. Всё работает и без него.", + "aiSetupCta": "Настроить ИИ" }, "welcomeBack": { "title": "С возвращением", @@ -5198,37 +5200,6 @@ "allergiesPlaceholder": "например, пенициллин, лактоза", "privacyNote": "Хранится в зашифрованном виде и используется только для лучшего контекста. Здесь мы не спрашиваем о ваших лекарствах. Добавляйте их в любое время на странице «Лекарства»." }, - "ai": { - "panelTitle": "Посмотрите, как выглядит аналитика", - "panelIntro": "Коуч, ежедневная сводка и заметки по каждому показателю превращают ваши числа в понятный язык. Вот как это выглядит, прежде чем вы что-либо подключите.", - "showSample": "Посмотреть пример сводки", - "sampleTitle": "Ежедневная сводка", - "sampleTag": "Пример", - "sampleAriaLabel": "Пример ежедневной сводки", - "sampleBody": "Вес снизился на 1,2 кг за две недели. Пульс в покое стабилен на 58 уд/мин. АД в диапазоне 6 из 7 дней; единственное высокое измерение последовало за поздним солёным ужином.", - "sampleCaption": "Только пример, не ваши данные. Так выглядит сводка, когда аналитика включена.", - "ladderTitle": "Три способа включить", - "ladderLocalTitle": "Локальная модель: запросы ИИ остаются в вашей сети", - "ladderLocalBody": "Направьте HealthLog на Ollama или LM Studio на вашей машине. Бесплатно и полностью приватно — спокойный выбор по умолчанию.", - "ladderByokTitle": "Свой ключ", - "ladderByokBody": "Используйте свой ключ OpenAI или Anthropic. Он работает под вашим аккаунтом и оплатой, а API-тариф по умолчанию не использует ваши данные для обучения.", - "ladderOauthTitle": "Вход по подписке", - "ladderOauthBody": "Подключите аккаунт ChatGPT. Без оплаты за токены, но условия потребительского тарифа могут использовать контент для обучения, поэтому это осознанный выбор, а не значение по умолчанию для ваших данных о здоровье.", - "keylessLine": "HealthLog полностью полезен и без ИИ. Ваши плитки, графики, динамика и экспорт работают. ИИ лишь добавляет понятное описание сверху.", - "setupCta": "Настроить (около минуты)", - "sharedKeyNote": "Этот экземпляр уже предоставляет общий провайдер ИИ, поэтому аналитика работает у вас прямо сейчас, без настройки.", - "offer": { - "title": "Этот экземпляр может написать вашу сводку понятным языком.", - "body": "Оператор предоставляет провайдера, который последний раз работал в течение последних 24 часов. Одно нажатие включает понятное описание; ваши данные затем уходят только этому провайдеру и никому больше.", - "cta": "Включить понятный язык", - "granted": "Включено. Первая сводка появится на вашей сводке.", - "error": "Включить не удалось. Попробуйте ещё раз через минуту." - }, - "unavailable": { - "title": "Провайдер этого экземпляра сейчас не отвечает.", - "body": "HealthLog полностью полезен и без понятного описания. Вы можете добавить своего провайдера позже в настройках." - } - }, "steps": { "who": "Для кого эта запись", "areas": "За чем следить", diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 4838bb877..c07c8c402 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 343, - "total": 111236, + "count": 342, + "total": 110828, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -177,7 +177,6 @@ "components/mood/mood-list.tsx :: MoodList": 1109, "components/mood/mood-tag-picker.tsx :: MoodTagPicker": 257, "components/onboarding/confirm-screen.tsx :: ConfirmScreen": 255, - "components/onboarding/done-screen.tsx :: DoneScreen": 408, "components/onboarding/getting-started-checklist.tsx :: GettingStartedChecklist": 423, "components/onboarding/tour.tsx :: OnboardingTour": 544, "components/practitioners/practitioner-list.tsx :: PractitionerList": 230, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 24c8dc829..1f4e2c3bd 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2892, - "total": 9217, + "count": 2891, + "total": 9215, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1257,7 +1257,6 @@ "components/onboarding/__tests__/anamnesis-card.test.tsx": 1, "components/onboarding/__tests__/confirm-failure-recovery.test.ts": 1, "components/onboarding/__tests__/confirm-screen.test.tsx": 1, - "components/onboarding/__tests__/done-screen.test.tsx": 1, "components/onboarding/__tests__/first-result-screen.test.tsx": 2, "components/onboarding/__tests__/getting-started-checklist.test.tsx": 1, "components/onboarding/__tests__/question-screen.test.tsx": 1, @@ -1268,7 +1267,7 @@ "components/onboarding/baseline-fields.tsx": 1, "components/onboarding/baseline-form.tsx": 3, "components/onboarding/confirm-screen.tsx": 5, - "components/onboarding/done-screen.tsx": 2, + "components/onboarding/done-screen.tsx": 1, "components/onboarding/first-result-screen.tsx": 3, "components/onboarding/getting-started-checklist.tsx": 13, "components/onboarding/onboarding-shell.tsx": 1, diff --git a/src/__tests__/consent-grant-explicit-guard.test.ts b/src/__tests__/consent-grant-explicit-guard.test.ts index 110e9c2ad..f741a8264 100644 --- a/src/__tests__/consent-grant-explicit-guard.test.ts +++ b/src/__tests__/consent-grant-explicit-guard.test.ts @@ -154,9 +154,10 @@ describe("AI consent is granted only by the person's own tap", () => { } } } - // Non-zero floors: a matcher that stops matching is red, not green. - expect(callers).toBeGreaterThanOrEqual(3); - expect(sites).toBeGreaterThanOrEqual(3); + // Non-zero floors: a matcher that stops matching is red, not green. Two + // callers today: the consent request and the document-reading prompt. + expect(callers).toBeGreaterThanOrEqual(2); + expect(sites).toBeGreaterThanOrEqual(2); }); it("keeps both grant routes refusing a request without the confirmation", () => { diff --git a/src/components/onboarding/__tests__/done-screen.test.tsx b/src/components/onboarding/__tests__/done-screen.test.tsx index e704d891a..f9113451e 100644 --- a/src/components/onboarding/__tests__/done-screen.test.tsx +++ b/src/components/onboarding/__tests__/done-screen.test.tsx @@ -4,60 +4,20 @@ import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; /** - * The elevated done-screen AI panel must lead with value and gate nothing: - * it shows the local-first ladder and the honest "useful without AI" line, - * yet the three original exits (connect / log / dashboard) stay intact, so - * a keyless user is never stranded. The shared-key note only appears when - * the deployment's provider serves the user (`managedBy === "server"`). + * The setup flow's exit. AI gets one quiet line with a link to Settings, AI, + * and only while it could be set up here and is still wanted; the exits + * (dashboard, tour, connect, log) are there either way. */ -const aiProviderState = vi.hoisted(() => ({ - data: undefined as - | { - managedBy?: string | null; - serverProviderHealth?: string; - serverProviderOffer?: boolean; - serverProviderConsent?: boolean; - } - | undefined, -})); - -/** - * The consent grant is a mutation, and this suite renders to static markup - * (the house convention — there is no jsdom in this repo), so the click - * itself cannot be dispatched. What IS worth proving is the request the - * button would send, so every `useMutation` registration is captured here - * and its `mutationFn` invoked directly. - */ -const mutations = vi.hoisted( - () => - [] as Array<{ - mutationKey?: readonly unknown[]; - mutationFn?: () => Promise; - }>, -); - vi.mock("@tanstack/react-query", () => ({ - useQuery: () => ({ data: aiProviderState.data }), - useMutation: (options?: { - mutationKey?: readonly unknown[]; - mutationFn?: () => Promise; - }) => { - if (options) mutations.push(options); - return { mutate: () => {}, mutateAsync: async () => {}, isPending: false }; - }, + useMutation: () => ({ + mutate: () => {}, + mutateAsync: async () => {}, + isPending: false, + }), useQueryClient: () => ({ invalidateQueries: async () => {} }), })); -const apiPost = vi.hoisted(() => vi.fn()); -vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({ - ...(await importOriginal>()), - apiPost, -})); - -const toastError = vi.hoisted(() => vi.fn()); -vi.mock("sonner", () => ({ toast: { error: toastError } })); - vi.mock("next/navigation", () => ({ useRouter: () => ({ push: () => {}, replace: () => {} }), })); @@ -67,19 +27,23 @@ vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ user: { id: "u1", modules: auth.modules } }), })); -/** - * `ai.provider.canConfigure` from `/api/auth/me`: the panel is one optional - * offer, shown only while AI can be set up from here. - */ -const providerState = vi.hoisted(() => ({ canConfigure: true })); +/** `ai.provider` from `/api/auth/me`. */ +const providerState = vi.hoisted(() => ({ + configured: false, + canConfigure: true, +})); vi.mock("@/hooks/use-ai-capability", () => ({ useAiProviderState: () => ({ - configured: false, + configured: providerState.configured, managedBy: null, canConfigure: providerState.canConfigure, }), })); +vi.mock("@/components/onboarding/use-onboarding-flow", () => ({ + useOnboardingAnswer: () => ({ mutate: () => {} }), +})); + vi.mock("@/hooks/use-account-switch", () => ({ useAccountSwitch: () => ({ mutate: () => {}, isPending: false }), })); @@ -129,41 +93,64 @@ function render(recordTarget?: OnboardingStateDto["needs"]["recordTarget"]) { ); } -describe(" AI panel", () => { - it("leads with the value-first panel and the local-first ladder", () => { - aiProviderState.data = undefined; - const html = render(); - expect(html).toContain('data-slot="onboarding-ai-panel"'); - // Local model is surfaced first (the calm private default). - expect(html).toContain("AI requests stay on your network"); - // The honest release valve is present and prominent. - expect(html).toContain('data-slot="onboarding-ai-keyless"'); - expect(html).toContain("fully useful without AI"); - // Setup is a single optional deep-link, not a gate. - expect(html).toContain('href="/settings/ai"'); - }); - - it("leaves the panel out when AI cannot be set up here", () => { - // The operator switched AI off, or the record belongs to somebody else. - // HealthLog is complete without AI, so the screen goes from "done" - // straight to the exits. - providerState.canConfigure = false; - aiProviderState.data = undefined; - const html = render(); - expect(html).not.toContain('data-slot="onboarding-ai-panel"'); - expect(html).toContain('data-slot="onboarding-open-dashboard"'); - providerState.canConfigure = true; - }); - - it("leaves the panel out once the Coach and AI analysis are switched off", () => { - auth.modules = { coach: false, insights: false }; - aiProviderState.data = undefined; - expect(render()).not.toContain('data-slot="onboarding-ai-panel"'); +function withState( + next: Partial, + modules: Record, + run: () => void, +) { + const before = { ...providerState }; + Object.assign(providerState, next); + auth.modules = modules; + try { + run(); + } finally { + Object.assign(providerState, before); auth.modules = {}; + } +} + +describe(" AI line", () => { + it("is one line with a link to Settings, AI, and nothing more", () => { + const html = render(); + const line = html.match( + /]*data-slot="onboarding-ai-line"[^>]*>([\s\S]*?)<\/p>/, + ); + expect(line).not.toBeNull(); + expect(line![1]).toContain("AI is optional"); + expect(line![1]).toContain('href="/settings/ai"'); + // No sample briefing, no provider ladder, no one-tap offer. + expect(html).not.toContain("Example"); + expect(html).not.toContain("Three ways to turn it on"); + expect(html).not.toMatch(/]*>[^<]*Turn on/); }); - it("keeps every exit so the panel is fully skippable", () => { - aiProviderState.data = undefined; + it("is left out when AI cannot be set up here", () => { + // The operator switched AI off, or the record belongs to somebody else. + withState({ canConfigure: false }, {}, () => { + const html = render(); + expect(html).not.toContain('data-slot="onboarding-ai-line"'); + expect(html).toContain('data-slot="onboarding-open-dashboard"'); + }); + }); + + it("is left out once a provider is configured", () => { + withState({ configured: true }, {}, () => { + expect(render()).not.toContain('data-slot="onboarding-ai-line"'); + }); + }); + + it("is left out once the Coach and AI analysis are switched off", () => { + withState({}, { coach: false, insights: false }, () => { + expect(render()).not.toContain('data-slot="onboarding-ai-line"'); + }); + withState({}, { coach: false, insights: true }, () => { + expect(render()).toContain('data-slot="onboarding-ai-line"'); + }); + }); +}); + +describe(" exits", () => { + it("keeps every exit", () => { const html = render(); expect(html).toContain('href="/settings/integrations"'); expect(html).toContain('href="/measurements"'); @@ -171,229 +158,11 @@ describe(" AI panel", () => { }); it("offers the tour rather than launching it", () => { - aiProviderState.data = undefined; expect(render()).toContain('data-slot="onboarding-take-tour"'); }); it("offers the managed profile only to an answer that asked for it later", () => { - aiProviderState.data = undefined; expect(render("both")).toContain('href="/settings/access"'); expect(render("me")).not.toContain('href="/settings/access"'); }); }); - -/** - * v1.38.19 — the shared provider, offered only when it works. - * - * The card used to read `managedBy === "server"` and promise that insights - * "work for you right now". That is a presence read: it knew a key was - * configured, never that it worked, and it said nothing about the consent - * receipt the egress actually needs — so a fresh account followed the - * promise straight into a `consent.ai.required` refusal. On 2026-09-11 the - * operator's own instance was answering HTTP 500 while the screen kept - * making that promise. - * - * Four variants now, and only one of them has a button. - */ -function grantMutation() { - return mutations.find((m) => - (m.mutationKey ?? []).some( - (part) => typeof part === "string" && part.includes("consent"), - ), - ); -} - -describe(" shared-provider offer", () => { - it("offers one tap when the server says the provider is known to work", () => { - aiProviderState.data = { - managedBy: "server", - serverProviderHealth: "healthy", - serverProviderOffer: true, - serverProviderConsent: false, - }; - const html = render(); - expect(html).toContain('data-ai-state="offer"'); - expect(html).toContain('data-slot="onboarding-ai-offer"'); - expect(html).toContain('data-slot="onboarding-ai-offer-grant"'); - // The unproven promise is gone from this branch. - expect(html).not.toContain('data-slot="onboarding-ai-shared-key"'); - expect(html).not.toContain('data-slot="onboarding-ai-unavailable"'); - }); - - it("says the provider is not answering only when a failure earned it", () => { - aiProviderState.data = { - managedBy: "server", - serverProviderHealth: "unhealthy", - serverProviderOffer: false, - serverProviderConsent: false, - }; - const html = render(); - expect(html).toContain('data-ai-state="unavailable"'); - expect(html).toContain('data-slot="onboarding-ai-unavailable"'); - expect(html).not.toContain('data-slot="onboarding-ai-offer-grant"'); - expect(html).not.toContain('data-slot="onboarding-ai-shared-key"'); - // The rest of the panel is untouched in every branch. - expect(html).toContain('data-slot="onboarding-ai-keyless"'); - expect(html).toContain('href="/settings/ai"'); - }); - - it("claims nothing either way when nobody has measured the provider", () => { - // `unknown` is an empty `provider_health` table — the state of every - // instance until its first AI call, and the most common way through this - // screen. Saying "not answering" there is a claim about something nobody - // measured, which is the one thing this panel exists not to do. - aiProviderState.data = { - managedBy: "server", - serverProviderHealth: "unknown", - serverProviderOffer: false, - serverProviderConsent: false, - }; - const html = render(); - expect(html).toContain('data-ai-state="neutral"'); - expect(html).not.toContain('data-slot="onboarding-ai-unavailable"'); - expect(html).not.toContain('data-slot="onboarding-ai-offer-grant"'); - expect(html).not.toContain('data-slot="onboarding-ai-shared-key"'); - // The neutral panel is the whole panel, minus any claim about a provider. - expect(html).toContain('data-slot="onboarding-ai-keyless"'); - expect(html).toContain('href="/settings/ai"'); - }); - - it("keeps the shared-key note for the one state it was ever true of", () => { - aiProviderState.data = { - managedBy: "server", - serverProviderHealth: "healthy", - serverProviderOffer: false, - serverProviderConsent: true, - }; - const html = render(); - expect(html).toContain('data-ai-state="consent"'); - expect(html).toContain('data-slot="onboarding-ai-shared-key"'); - expect(html).not.toContain('data-slot="onboarding-ai-offer-grant"'); - expect(html).not.toContain('data-slot="onboarding-ai-unavailable"'); - }); - - it("leaves a user with their own provider the setup link and nothing else", () => { - aiProviderState.data = { - managedBy: "user", - serverProviderHealth: "healthy", - serverProviderOffer: false, - serverProviderConsent: false, - }; - const html = render(); - expect(html).not.toContain('data-slot="onboarding-ai-offer"'); - expect(html).not.toContain('data-slot="onboarding-ai-unavailable"'); - expect(html).not.toContain('data-slot="onboarding-ai-shared-key"'); - expect(html).toContain('href="/settings/ai"'); - }); - - it("says nothing at all while the status is still in flight", () => { - aiProviderState.data = undefined; - const html = render(); - // Not even the settled marker: the question is genuinely unanswered. - expect(html).not.toContain("data-ai-state"); - expect(html).not.toContain('data-slot="onboarding-ai-offer"'); - expect(html).not.toContain('data-slot="onboarding-ai-unavailable"'); - }); - - /** - * The whole decision table, in one place. - * - * Three arms the screen can be in — the offer is available, the offer is - * withheld for a reason that is not health (the assistant surfaces switched - * off, a managed profile), a receipt is already on file — crossed with the - * three health states. The point of the cross is that HEALTH decides the - * claim in every arm: the consent arm used to promise "insights work for - * you right now" without reading health at all, which told somebody who had - * consented that a dead provider was working. That is the same unmeasured - * claim as the `unknown` case at the other end of the panel. - */ - const TABLE = [ - { arm: "offerable", consent: false, health: "healthy", state: "offer" }, - { - arm: "offerable", - consent: false, - health: "unhealthy", - state: "unavailable", - }, - { arm: "offerable", consent: false, health: "unknown", state: "neutral" }, - { arm: "withheld", consent: false, health: "healthy", state: "neutral" }, - { - arm: "withheld", - consent: false, - health: "unhealthy", - state: "unavailable", - }, - { arm: "withheld", consent: false, health: "unknown", state: "neutral" }, - { arm: "consented", consent: true, health: "healthy", state: "consent" }, - { - arm: "consented", - consent: true, - health: "unhealthy", - state: "unavailable", - }, - { arm: "consented", consent: true, health: "unknown", state: "neutral" }, - ] as const; - - it.each(TABLE)( - "settles on $state when the offer is $arm and the provider is $health", - ({ arm, consent, health, state }) => { - aiProviderState.data = { - managedBy: "server", - serverProviderHealth: health, - // The server only ever offers on `healthy`, and never with a receipt - // on file; the withheld arm is the flags-off / managed-profile case. - serverProviderOffer: arm === "offerable" && health === "healthy", - serverProviderConsent: consent, - }; - const html = render(); - expect(html).toContain(`data-ai-state="${state}"`); - // The positive sentence appears in exactly one of the nine cells. - expect(html.includes('data-slot="onboarding-ai-shared-key"')).toBe( - state === "consent", - ); - expect(html.includes('data-slot="onboarding-ai-unavailable"')).toBe( - state === "unavailable", - ); - expect(html.includes('data-slot="onboarding-ai-offer-grant"')).toBe( - state === "offer", - ); - // The rest of the panel is untouched in every cell. - expect(html).toContain('data-slot="onboarding-ai-keyless"'); - expect(html).toContain('href="/settings/ai"'); - }, - ); - - it("grants only with the explicit confirmation the server requires", async () => { - mutations.length = 0; - apiPost.mockReset(); - apiPost.mockResolvedValue({ minted: true, kind: "ai_full" }); - aiProviderState.data = { - managedBy: "server", - serverProviderHealth: "healthy", - serverProviderOffer: true, - serverProviderConsent: false, - }; - render(); - const grant = grantMutation(); - expect(grant).toBeDefined(); - await grant!.mutationFn!(); - expect(apiPost).toHaveBeenCalledWith("/api/consent/ai/web", { - confirm: true, - }); - }); - - it("a refused grant throws rather than reporting a consent nobody recorded", async () => { - mutations.length = 0; - apiPost.mockReset(); - apiPost.mockRejectedValue(new Error("429")); - aiProviderState.data = { - managedBy: "server", - serverProviderHealth: "healthy", - serverProviderOffer: true, - serverProviderConsent: false, - }; - render(); - const grant = grantMutation(); - await expect(grant!.mutationFn!()).rejects.toThrow(); - }); -}); diff --git a/src/components/onboarding/__tests__/sample-briefing-card.test.tsx b/src/components/onboarding/__tests__/sample-briefing-card.test.tsx deleted file mode 100644 index 6d3b38cc1..000000000 --- a/src/components/onboarding/__tests__/sample-briefing-card.test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, it, expect, vi, afterEach } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { I18nProvider } from "@/lib/i18n/context"; -import { SampleBriefingCard } from "../sample-briefing-card"; - -/** - * The onboarding "aha" artifact must be a STATIC, clearly-labelled example - * with zero egress: no provider is configured when it renders, so it can - * never make a model call. These tests pin both properties — the "Example" - * tag is present (so it's never mistaken for real data) and nothing on the - * component touches the network. - */ - -function render(node: React.ReactElement) { - return renderToStaticMarkup( - {node}, - ); -} - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe("", () => { - it("renders the sample briefing with an explicit Example tag", () => { - const html = render(); - expect(html).toContain("Example"); - // The worked-example numbers are fixed copy, never derived from a user. - expect(html).toContain("1.2 kg"); - expect(html).toContain('data-slot="sample-briefing-tag"'); - // The caption states plainly that this is not the user's data. - expect(html).toContain("not your own data"); - }); - - it("makes no network call — no provider exists yet, zero egress", () => { - const fetchSpy = vi.spyOn(globalThis, "fetch"); - render(); - expect(fetchSpy).not.toHaveBeenCalled(); - }); -}); diff --git a/src/components/onboarding/done-screen.tsx b/src/components/onboarding/done-screen.tsx index 7e86bcc4e..c95709db9 100644 --- a/src/components/onboarding/done-screen.tsx +++ b/src/components/onboarding/done-screen.tsx @@ -2,10 +2,8 @@ import { useAiProviderState } from "@/hooks/use-ai-capability"; import Link from "next/link"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; -import { useQuery } from "@tanstack/react-query"; -import { showErrorToast } from "@/lib/errors/error-toast"; import { CheckCircle2, Compass, @@ -13,21 +11,15 @@ import { PlusCircle, Plug, Settings2, - Sparkles, UserPlus, } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { MedicalDisclaimer } from "@/components/common/medical-disclaimer"; -import { SampleBriefingCard } from "@/components/onboarding/sample-briefing-card"; import { StepHeading } from "@/components/onboarding/step-heading"; 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 { apiGet } from "@/lib/api/api-fetch"; -import { useGrantAiConsent } from "@/hooks/use-grant-ai-consent"; import { hasOptedOutOfAi } from "@/lib/ai/capabilities/types"; import { useTranslations } from "@/lib/i18n/context"; import { markChecklistExpanded } from "@/lib/onboarding/checklist-storage"; @@ -35,21 +27,6 @@ import type { OnboardingStateDto } from "@/lib/onboarding/needs"; import { accountLabel } from "@/lib/sharing/account-access-view"; import { InlineAction } from "@/components/ui/inline-action"; -interface AiProviderStatus { - /** Origin of the provider that would serve this user, if any. */ - managedBy?: "user" | "local" | "server" | null; - /** - * v1.38.19 — the instance-wide tri-state. Fail closed for the - * offer: neither `unhealthy` nor `unknown` earns one. Only `unhealthy` is - * a verdict this screen may state out loud. - */ - serverProviderHealth?: "healthy" | "unhealthy" | "unknown"; - /** Whether the shared provider may honestly be offered in one tap. */ - serverProviderOffer?: boolean; - /** Whether this user already holds an active `ai_full` / `ai_coach` receipt. */ - serverProviderConsent?: boolean; -} - /** * v1.39 — the setup flow's exit. * @@ -69,18 +46,14 @@ interface AiProviderStatus { * is presentational: the module map was derived on confirm, the cookie * cleared there too. * - * The AI panel leads with the payoff (a static, clearly-labelled SAMPLE - * briefing — no model call, no egress, no consent), then the honest - * local-first ladder and the "useful without AI" release valve. Value-first, - * never a gate: every exit below stays, and setup is a single optional - * deep-link. + * AI gets one line with a link to Settings, AI, and no more: HealthLog is + * complete without it, and the hint on Insights is where it is offered. */ export function DoneScreen({ state }: { state: OnboardingStateDto }) { const { t } = useTranslations(); const router = useRouter(); const { user } = useAuth(); - const [sampleOpen, setSampleOpen] = useState(false); const answer = useOnboardingAnswer(); const accountSwitch = useAccountSwitch(); @@ -116,87 +89,15 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) { router.push("/"); } - // ── v1.38.19 — the shared provider, offered only when it works ── - // - // This block used to be one line: `managedBy === "server"` painted a note - // reading "insights work for you right now — no setup needed". Both halves - // were wrong. `managedBy` is a PRESENCE read — it knows the operator - // configured a key, never that the key answers — and "no setup needed" - // skipped the consent receipt `consent-guard.ts` demands before any health - // data reaches `admin-openai` / `admin-codex`, so the person who believed - // the note walked into a `consent.ai.required` refusal on their first - // briefing. On 2026-09-11 the operator's own instance was answering HTTP - // 500 from its OAuth proxy while this note kept promising otherwise. - // - // The server decides now, and the four branches below only READ it. The - // rest of the panel — sample, ladder, the "fully useful without AI" line, - // the setup link — is identical in every branch; nothing here is a gate. - // The panel is one optional offer, shown only while this person could set - // AI up here: `provider.canConfigure` is false while the operator has AI - // switched off and inside a record somebody else owns. HealthLog is - // complete without it, so with nothing to offer (or once the person - // switched the Coach and AI analysis off) the screen simply goes from - // "done" to the exits. - const providerState = useAiProviderState(); - const showAiPanel = - providerState.canConfigure && !hasOptedOutOfAi(user?.modules); - const { data: aiProvider } = useQuery({ - queryKey: queryKeys.userAiProvider(), - queryFn: async () => { - return apiGet("/api/user/ai-provider"); - }, - enabled: !!user && showAiPanel, - }); - - // The tap's own outcome, held locally so the line changes the moment the - // receipt is minted rather than a refetch later. - const [justGranted, setJustGranted] = useState(false); - // The tap on the offer is the consent act; the hook sends the explicit - // confirmation the server requires before it records one. - const grantConsent = useGrantAiConsent(); - function agreeToSharedProvider() { - grantConsent.mutate(undefined, { - onSuccess: () => setJustGranted(true), - onError: (failure) => { - // The button stays. A failed grant is a failed grant; the screen - // must not read as though consent were on file. - showErrorToast(t("onboarding.ai.offer.error"), { cause: failure }); - }, - }); - } - - const operatorProvides = aiProvider?.managedBy === "server"; - const sharedHealth = aiProvider?.serverProviderHealth; - const consentOnFile = - justGranted || aiProvider?.serverProviderConsent === true; - const offerShared = - operatorProvides && - !consentOnFile && - aiProvider?.serverProviderOffer === true; - // A receipt on file is a fact about the ACCOUNT; it says nothing about - // whether the provider works. This line used to promise "insights work for - // you right now" on the strength of the receipt alone, so somebody who had - // just consented was told a dead provider was serving them — the same - // unmeasured claim as the offer's, made at the other end of the panel. The - // positive sentence is now earned by health as well, and a consented - // account on a broken provider reads the same honest note as anybody else. - const sharedKeyNote = - operatorProvides && consentOnFile && sharedHealth === "healthy"; - // Only a health verdict somebody EARNED paints the "not answering" card — - // `unhealthy`, which means the provider was tried and the last thing that - // happened was a failure. `unknown` is not that verdict: it is an empty - // ledger, which is the state of every instance until its first AI call and - // therefore the most common way through this screen. It falls to the - // neutral panel, as does every other non-health reason the offer is - // withheld (the assistant surfaces switched off, a managed profile whose - // provider is the operator's by definition). The neutral panel claims - // nothing either way, which is the only honest thing to say about a state - // nobody measured. - const sharedUnavailable = - operatorProvides && - !sharedKeyNote && - !offerShared && - sharedHealth === "unhealthy"; + // One quiet line, only while this person could set AI up here, has no + // provider yet and has not switched the Coach and AI analysis off: the same + // rule as the Insights hint, which stays the one place that invites. With + // nothing to offer the screen goes from "done" straight to the exits. + const provider = useAiProviderState(); + const showAiLine = + !provider.configured && + provider.canConfigure && + !hasOptedOutOfAi(user?.modules); return (
- {/* v1.28 — the flagship AI value, made reachable at the one screen - every fresh user passes through. The daily briefing / Coach need - a provider the rest of onboarding never provisions, so this - leads with the payoff (a static, clearly-labelled SAMPLE - briefing — no model call, no egress, no consent), then the - honest local-first ladder and the "useful without AI" release - valve. Value-first, never a gate: the three exits below stay, - and setup is a single optional deep-link. */} - {showAiPanel ? ( -
-
- -
-

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

-

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

-
-
- - {sampleOpen ? ( - - ) : ( - - )} - - {sharedKeyNote ? ( -

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

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

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

-

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

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

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

-

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

-
- ) : null} - -
-

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

-
    - {/* Local first — the calm, private default — then BYOK, then - the subscription/OAuth path with its training caveat. Same - ordering + vendor-blind framing as the shipped document- - provider governance. */} -
  • -

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

    -

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

    -
  • -
  • -

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

    -

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

    -
  • -
  • -

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

    -

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

    -
  • -
-
- -

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

- - - - - {t("onboarding.ai.setupCta")} + {t("onboarding.done.aiOptional")}{" "} + + {t("onboarding.done.aiSetupCta")} -
+

) : null}
diff --git a/src/components/onboarding/getting-started-checklist.tsx b/src/components/onboarding/getting-started-checklist.tsx index ba5fcae95..906c8c655 100644 --- a/src/components/onboarding/getting-started-checklist.tsx +++ b/src/components/onboarding/getting-started-checklist.tsx @@ -263,11 +263,9 @@ export function GettingStartedChecklist() { enabled: checklistRelevant, }); - // The AI row reads the provider state `/api/auth/me` already carries: - // present only while AI can be set up from here (`canConfigure`) and the - // person has not switched the Coach and AI analysis off, done once any - // provider is configured (a personal key, a local model, an OAuth sign-in or - // the operator's shared key). No request of its own. + // The AI row reads `/api/auth/me`: present while AI can be set up here and + // is still wanted (`canConfigure`, `hasOptedOutOfAi`), done once any + // provider is configured. No request of its own. const aiProvider = useAiProviderState(); // v1.5: skip these two fetches once the card can't render at diff --git a/src/components/onboarding/sample-briefing-card.tsx b/src/components/onboarding/sample-briefing-card.tsx deleted file mode 100644 index 29075092e..000000000 --- a/src/components/onboarding/sample-briefing-card.tsx +++ /dev/null @@ -1,58 +0,0 @@ -"use client"; - -import { Sparkles } from "lucide-react"; - -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { TileHeader } from "@/components/insights/tile-header"; -import { cn } from "@/lib/utils"; -import { useTranslations } from "@/lib/i18n/context"; - -/** - * The onboarding "aha" artifact — a static, clearly-labelled *example* - * daily briefing shown BEFORE any AI provider is connected. - * - * It exists so a fresh user feels the payoff of AI Insights with zero - * setup: no provider is configured yet, so this makes no model call, no - * fetch, no egress, and needs no consent. Every number is fixed copy - * from the `onboarding.ai.sample*` keys (the worked example the - * no-provider briefing empty state already carries) — it is never - * derived from the user's own record, and the "Example" badge plus the - * caption make that explicit so it is never mistaken for real data. - * - * Presentational only — no state, no effect, no data fetch. Reused by - * the onboarding done-screen panel; safe to drop into any surface that - * wants to show what a briefing reads like. - */ -export function SampleBriefingCard({ className }: { className?: string }) { - const { t } = useTranslations(); - - return ( - - - - {t("onboarding.ai.sampleTag")} - - } - /> - - -

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

-

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

-
-
- ); -}