From f22e39872e101c30a865b5a55edf3a99b5e95291 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 18:53:07 +0200 Subject: [PATCH 1/7] refactor(tz): route coach/verdict/mood day-keys through userDayKey The coach snapshot tzDayKey, the dashboard verdict dayKeyInTz, and the mood-entry moodDateKey each carried their own Intl day-key formatter (en-CA / en-CA / sv-SE). Delegate all three to the canonical userDayKey so every day-bucket key stays byte-aligned and the locale decision lives in one place. Behaviour-preserving. --- src/lib/ai/coach/snapshot.ts | 11 ++++------- src/lib/dashboard/verdict.ts | 13 ++----------- src/lib/mood/date-key.ts | 12 +++++++----- 3 files changed, 13 insertions(+), 23 deletions(-) diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index c7639ecfb..f8e82811a 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -23,6 +23,7 @@ import { type CoachDataCluster, } from "@/lib/validations/coach-prefs"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; +import { userDayKey } from "@/lib/tz/format"; import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; import { computeGlucoseClinicalMetrics, @@ -223,15 +224,11 @@ function windowToDays(window: CoachScopeWindow): number { * "last night" answer has to use the user's clock, not UTC. Up to * v1.4.24 the day-key was UTC, so a 23:50 Pacific/Auckland reading * landed in the next UTC day's bucket and the Coach couldn't pair it - * with the user's mental model. + * with the user's mental model. Delegates to the canonical + * `userDayKey` so every day-bucket surface stays byte-aligned. */ function tzDayKey(date: Date, tz: string): string { - return new Intl.DateTimeFormat("en-CA", { - timeZone: tz, - year: "numeric", - month: "2-digit", - day: "2-digit", - }).format(date); + return userDayKey(date, tz); } /** diff --git a/src/lib/dashboard/verdict.ts b/src/lib/dashboard/verdict.ts index 59c62e330..101667268 100644 --- a/src/lib/dashboard/verdict.ts +++ b/src/lib/dashboard/verdict.ts @@ -31,6 +31,7 @@ import { COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H, } from "@/lib/jobs/coach-nudge-thresholds"; import { buildWeightRangeFromHeight } from "@/lib/analytics/value-bands"; +import { userDayKey } from "@/lib/tz/format"; import type { DashboardSnapshot } from "@/lib/dashboard/snapshot"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -93,16 +94,6 @@ export interface DashboardVerdict { cta: DashboardVerdictCta | null; } -/** YYYY-MM-DD key of `d`'s calendar day in `tz`. */ -function dayKeyInTz(d: Date, tz: string): string { - return new Intl.DateTimeFormat("en-CA", { - timeZone: tz, - year: "numeric", - month: "2-digit", - day: "2-digit", - }).format(d); -} - /** HH:mm wall-clock of `d` in `tz`. */ function hmInTz(d: Date, tz: string): string { const parts = new Intl.DateTimeFormat("en-GB", { @@ -176,7 +167,7 @@ export function resolveDashboardVerdict( if ( untilDue >= 0 && untilDue <= DOSE_UPCOMING_WINDOW_MS && - dayKeyInTz(new Date(dueMs), tz) === dayKeyInTz(now, tz) + userDayKey(new Date(dueMs), tz) === userDayKey(now, tz) ) { return { variant: "doseUpcoming", diff --git a/src/lib/mood/date-key.ts b/src/lib/mood/date-key.ts index eb7801697..440561f0a 100644 --- a/src/lib/mood/date-key.ts +++ b/src/lib/mood/date-key.ts @@ -28,6 +28,8 @@ * legacy date string (`effectiveMoodTz`). */ +import { userDayKey } from "@/lib/tz/format"; + export const DEFAULT_TIMEZONE = "Europe/Berlin"; /** @@ -35,14 +37,14 @@ export const DEFAULT_TIMEZONE = "Europe/Berlin"; * timezone at write time. Used by `POST /api/mood-entries` and the * `PUT /api/mood-entries/:id` route when `moodLoggedAt` changes. * - * The Swedish locale ("sv-SE") is the historical choice for ISO 8601 - * day output via `Intl.DateTimeFormat`; we keep it so the new path - * produces byte-identical strings to the legacy `toBerlinDate()` for - * Europe/Berlin users (the only path tested under the v1.4.24 suite). + * Delegates to the canonical `userDayKey` so the mood day-bucket key + * stays byte-identical to every other day-bucket surface. The ISO + * YYYY-MM-DD shape is unchanged from the legacy sv-SE formatter; the + * empty-tz fallback to Europe/Berlin is preserved here. */ export function moodDateKey(date: Date, tz: string): string { const safe = tz && tz.length > 0 ? tz : DEFAULT_TIMEZONE; - return new Intl.DateTimeFormat("sv-SE", { timeZone: safe }).format(date); + return userDayKey(date, safe); } /** From 63bca26177d3435fb4bd9d7010fd008faedd226c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 19:02:05 +0200 Subject: [PATCH 2/7] refactor(dashboard): extract buildDashboardBands into a client-safe leaf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The band / target math lived inline in two places — the server snapshot builder and the dashboard page's snapshot-disabled fallback — so a threshold change had to be hand-mirrored or the two surfaces would grade the same metric differently. Move the construction into the new client-safe @/lib/dashboard/bands leaf (buildDashboardBands, with buildTargetBands kept as an alias for the snapshot-era call sites) and have both the snapshot builder and the page fallback call it. The page always computes the client copy from the profile so a still-loading snapshot frame keeps real bands, then prefers the server numbers once they arrive. Behaviour-preserving. --- src/app/page.tsx | 106 ++++++------------------- src/lib/dashboard/bands.ts | 143 ++++++++++++++++++++++++++++++++++ src/lib/dashboard/snapshot.ts | 125 ++--------------------------- 3 files changed, 173 insertions(+), 201 deletions(-) create mode 100644 src/lib/dashboard/bands.ts diff --git a/src/app/page.tsx b/src/app/page.tsx index c7a111143..8de115c44 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -106,18 +106,7 @@ import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; import type { DataSummary } from "@/lib/analytics/trends"; import { mergeSlimAndThickAnalytics } from "@/lib/analytics/merge-slim-thick"; import { isWindowSufficient } from "@/lib/analytics/window-confidence"; -import { getBpTargets } from "@/lib/analytics/bp-targets"; -import { - buildTrafficLightBands, - buildTrafficRange, - buildWeightBandsFromHeight, - buildWeightRangeFromHeight, - getBodyFatTargetRange, -} from "@/lib/analytics/value-bands"; -import { - getAgeFromDateOfBirth, - getPersonalizedPulseTarget, -} from "@/lib/analytics/pulse-targets"; +import { buildDashboardBands } from "@/lib/dashboard/bands"; import { apiGet } from "@/lib/api/api-fetch"; // v1.18.6 — the pure first-paint / skeleton-reservation gates moved to @@ -537,34 +526,22 @@ export default function DashboardPage() { const serverBands = snapshotEnabled ? snapshotQuery.data?.targetBands : undefined; - const bpTargets = snapshotEnabled - ? (serverBands?.bpTargets ?? null) - : user?.dateOfBirth != null - ? getBpTargets(new Date(user.dateOfBirth)) - : null; - const bodyFatRange = snapshotEnabled - ? (serverBands?.bodyFatRange ?? getBodyFatTargetRange(user?.gender)) - : getBodyFatTargetRange(user?.gender); - const weightRange = snapshotEnabled - ? (serverBands?.weightRange ?? null) - : user?.heightCm - ? buildWeightRangeFromHeight(user.heightCm) - : null; - const weightBands = snapshotEnabled - ? (serverBands?.weightBands ?? undefined) - : user?.heightCm - ? buildWeightBandsFromHeight(user.heightCm, { - lowerBound: 30, - upperBound: 250, - }) - : undefined; - // The pulse target range / bands carry no i18n — read them server-side - // under snapshot mode; recompute from the profile otherwise. - const pulseAge = getAgeFromDateOfBirth(user?.dateOfBirth ?? null); - const pulseTarget = getPersonalizedPulseTarget( - pulseAge, - (user?.gender as "MALE" | "FEMALE" | null | undefined) ?? null, - ); + // Resolve the bands through the SAME `buildDashboardBands` helper the + // server snapshot uses, so the snapshot path and the client fallback + // are byte-identical by construction (no second inline copy of the + // band math to drift). Always compute the client copy from the profile + // — it is pure + cheap — so a still-loading snapshot frame keeps real + // bands rather than blank charts; `serverBands ?? clientBands` then + // prefers the authoritative server numbers the moment they arrive. + const clientBands = buildDashboardBands({ + dateOfBirth: user?.dateOfBirth ? new Date(user.dateOfBirth) : null, + gender: (user?.gender as "MALE" | "FEMALE" | null | undefined) ?? null, + heightCm: user?.heightCm ?? null, + }); + const bands = serverBands ?? clientBands; + const bpTargets = bands.bpTargets; + const weightRange = bands.weightRange; + const weightBands = bands.weightBands ?? undefined; const bpTargetZones = bpTargets ? [ { @@ -587,50 +564,11 @@ export default function DashboardPage() { }, ] : undefined; - const bpSysRange = snapshotEnabled - ? (serverBands?.bpSysRange ?? null) - : bpTargets - ? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh) - : null; - const bpDiaRange = snapshotEnabled - ? (serverBands?.bpDiaRange ?? null) - : bpTargets - ? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh) - : null; - const pulseDisplayRange = serverBands?.pulseDisplayRange ?? { - greenMin: pulseTarget.greenMin, - greenMax: pulseTarget.greenMax, - orangeMin: pulseTarget.orangeMin, - orangeMax: pulseTarget.orangeMax, - }; - const pulseBands = serverBands?.pulseBands ?? [ - { min: 30, max: pulseTarget.orangeMin, color: "#ff5555", opacity: 0.16 }, - { - min: pulseTarget.orangeMin, - max: pulseTarget.greenMin, - color: "#ffb86c", - opacity: 0.18, - }, - { - min: pulseTarget.greenMin, - max: pulseTarget.greenMax, - color: "#50fa7b", - opacity: 0.2, - }, - { - min: pulseTarget.greenMax, - max: pulseTarget.orangeMax, - color: "#ffb86c", - opacity: 0.18, - }, - { min: pulseTarget.orangeMax, max: 220, color: "#ff5555", opacity: 0.16 }, - ].filter((band) => band.max > band.min); - const bodyFatBands = - serverBands?.bodyFatBands ?? - buildTrafficLightBands(bodyFatRange.min, bodyFatRange.max, { - lowerBound: 2, - upperBound: 55, - }); + const bpSysRange = bands.bpSysRange; + const bpDiaRange = bands.bpDiaRange; + const pulseDisplayRange = bands.pulseDisplayRange; + const pulseBands = bands.pulseBands; + const bodyFatBands = bands.bodyFatBands; // v1.18.6 — ONE batched fetch for every visible non-sleep chart series // (audit finding #2). Pre-fix, each `` fired its own diff --git a/src/lib/dashboard/bands.ts b/src/lib/dashboard/bands.ts new file mode 100644 index 000000000..8f2d1aed0 --- /dev/null +++ b/src/lib/dashboard/bands.ts @@ -0,0 +1,143 @@ +/** + * Dashboard band / target math — the single source of truth. + * + * Pure projection over a user's profile facts (DOB / gender / height): + * no DB read, no clock, no network, no `server-only` dep. Every building + * block it composes (`@/lib/analytics/{value-bands,bp-targets,pulse-targets}`) + * is itself client-safe, so this leaf imports cleanly into both the + * server-side snapshot builder (`@/lib/dashboard/snapshot`) and the + * `"use client"` dashboard page fallback (`src/app/page.tsx`). + * + * Before v1.18.6 the band construction lived inline in two places — the + * snapshot builder and the page's snapshot-disabled fallback — so a + * threshold change had to be mirrored by hand or the two surfaces would + * grade the same metric differently. Both now call `buildDashboardBands`, + * so the band/target math lives in exactly one place. `buildTargetBands` + * is kept as an alias for the snapshot-era import sites. + */ +import { getBpTargets, type BpTargets } from "@/lib/analytics/bp-targets"; +import { + buildTrafficLightBands, + buildTrafficRange, + buildWeightBandsFromHeight, + buildWeightRangeFromHeight, + getBodyFatTargetRange, + type ValueBand, + type TrafficRange, +} from "@/lib/analytics/value-bands"; +import { + getAgeFromDateOfBirth, + getPersonalizedPulseTarget, +} from "@/lib/analytics/pulse-targets"; + +export interface DashboardTargetBands { + /** Personalised BP target numbers (null when no DOB). */ + bpTargets: BpTargets | null; + /** Systolic traffic range (null when no DOB). */ + bpSysRange: TrafficRange | null; + /** Diastolic traffic range (null when no DOB). */ + bpDiaRange: TrafficRange | null; + /** Resting-pulse display range (always present — AHA fallback). */ + pulseDisplayRange: { + greenMin: number; + greenMax: number; + orangeMin: number; + orangeMax: number; + }; + /** Resting-pulse chart bands (always present — AHA fallback). */ + pulseBands: ValueBand[]; + /** Body-fat target range (gender-aware; always present). */ + bodyFatRange: { min: number; max: number }; + /** Body-fat chart bands (always present). */ + bodyFatBands: ValueBand[]; + /** Weight traffic range (null when no height). */ + weightRange: TrafficRange | null; + /** Weight chart bands (null when no height). */ + weightBands: ValueBand[] | null; +} + +/** + * Resolve the band / target math from a user's profile facts. Pure; no + * DB read. The snapshot builder and the page fallback both call this so + * they produce byte-identical numbers. + */ +export function buildDashboardBands(profile: { + dateOfBirth: Date | null; + gender: "MALE" | "FEMALE" | null; + heightCm: number | null; +}): DashboardTargetBands { + const bpTargets = profile.dateOfBirth + ? getBpTargets(profile.dateOfBirth) + : null; + const pulseAge = getAgeFromDateOfBirth(profile.dateOfBirth); + const pulseTarget = getPersonalizedPulseTarget(pulseAge, profile.gender); + const bodyFatRange = getBodyFatTargetRange(profile.gender); + const weightRange = profile.heightCm + ? buildWeightRangeFromHeight(profile.heightCm) + : null; + const weightBands = profile.heightCm + ? buildWeightBandsFromHeight(profile.heightCm, { + lowerBound: 30, + upperBound: 250, + }) + : null; + const bpSysRange = bpTargets + ? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh) + : null; + const bpDiaRange = bpTargets + ? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh) + : null; + const pulseBands = [ + { min: 30, max: pulseTarget.orangeMin, color: "#ff5555", opacity: 0.16 }, + { + min: pulseTarget.orangeMin, + max: pulseTarget.greenMin, + color: "#ffb86c", + opacity: 0.18, + }, + { + min: pulseTarget.greenMin, + max: pulseTarget.greenMax, + color: "#50fa7b", + opacity: 0.2, + }, + { + min: pulseTarget.greenMax, + max: pulseTarget.orangeMax, + color: "#ffb86c", + opacity: 0.18, + }, + { min: pulseTarget.orangeMax, max: 220, color: "#ff5555", opacity: 0.16 }, + ].filter((band) => band.max > band.min); + const bodyFatBands = buildTrafficLightBands( + bodyFatRange.min, + bodyFatRange.max, + { + lowerBound: 2, + upperBound: 55, + }, + ); + return { + bpTargets, + bpSysRange, + bpDiaRange, + pulseDisplayRange: { + greenMin: pulseTarget.greenMin, + greenMax: pulseTarget.greenMax, + orangeMin: pulseTarget.orangeMin, + orangeMax: pulseTarget.orangeMax, + }, + pulseBands, + bodyFatRange, + bodyFatBands, + weightRange, + weightBands, + }; +} + +/** + * Snapshot-era alias. The snapshot builder and its parity test still + * import `buildTargetBands`; keep the name pointing at the canonical + * builder so neither has to change. + */ +export const buildTargetBands = buildDashboardBands; diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 969e4e9fa..668e38944 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -45,20 +45,7 @@ import { type DataPoint, type DataSummary, } from "@/lib/analytics/trends"; -import { getBpTargets, type BpTargets } from "@/lib/analytics/bp-targets"; -import { - buildTrafficLightBands, - buildTrafficRange, - buildWeightBandsFromHeight, - buildWeightRangeFromHeight, - getBodyFatTargetRange, - type ValueBand, - type TrafficRange, -} from "@/lib/analytics/value-bands"; -import { - getAgeFromDateOfBirth, - getPersonalizedPulseTarget, -} from "@/lib/analytics/pulse-targets"; +import { getBpTargets } from "@/lib/analytics/bp-targets"; import { computeGlucoseClinicalMetrics, GLUCOSE_PANEL_WINDOW_DAYS, @@ -90,6 +77,11 @@ import { type MedsTodayBlock, } from "@/lib/dashboard/meds-today"; import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path"; +import { + buildDashboardBands, + buildTargetBands, + type DashboardTargetBands, +} from "@/lib/dashboard/bands"; import type { HealthScoreBand, RestModeAnnotation, @@ -225,31 +217,8 @@ export interface DashboardSnapshotUser { * personalisation, no height → weight band), matching the client's * previous null-guards exactly. */ -export interface DashboardTargetBands { - /** Personalised BP target numbers (null when no DOB). */ - bpTargets: BpTargets | null; - /** Systolic traffic range (null when no DOB). */ - bpSysRange: TrafficRange | null; - /** Diastolic traffic range (null when no DOB). */ - bpDiaRange: TrafficRange | null; - /** Resting-pulse display range (always present — AHA fallback). */ - pulseDisplayRange: { - greenMin: number; - greenMax: number; - orangeMin: number; - orangeMax: number; - }; - /** Resting-pulse chart bands (always present — AHA fallback). */ - pulseBands: ValueBand[]; - /** Body-fat target range (gender-aware; always present). */ - bodyFatRange: { min: number; max: number }; - /** Body-fat chart bands (always present). */ - bodyFatBands: ValueBand[]; - /** Weight traffic range (null when no height). */ - weightRange: TrafficRange | null; - /** Weight chart bands (null when no height). */ - weightBands: ValueBand[] | null; -} +export { buildDashboardBands, buildTargetBands }; +export type { DashboardTargetBands }; export interface DashboardSnapshotMoodEntry { date: string; @@ -661,84 +630,6 @@ async function buildExtras( }; } -/** - * v1.18.6 — resolve the band / target math server-side from the same - * helpers the client used. Pure projection over the profile facts; no DB - * read. Mirrors the exact construction `page.tsx` ran (same helper calls, - * same literal pulse-band colours/opacities, same null-guards) so the - * snapshot path and the legacy client-compute path produce byte-identical - * numbers. - */ -export function buildTargetBands(profile: { - dateOfBirth: Date | null; - gender: "MALE" | "FEMALE" | null; - heightCm: number | null; -}): DashboardTargetBands { - const bpTargets = profile.dateOfBirth - ? getBpTargets(profile.dateOfBirth) - : null; - const pulseAge = getAgeFromDateOfBirth(profile.dateOfBirth); - const pulseTarget = getPersonalizedPulseTarget(pulseAge, profile.gender); - const bodyFatRange = getBodyFatTargetRange(profile.gender); - const weightRange = profile.heightCm - ? buildWeightRangeFromHeight(profile.heightCm) - : null; - const weightBands = profile.heightCm - ? buildWeightBandsFromHeight(profile.heightCm, { - lowerBound: 30, - upperBound: 250, - }) - : null; - const bpSysRange = bpTargets - ? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh) - : null; - const bpDiaRange = bpTargets - ? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh) - : null; - const pulseBands = [ - { min: 30, max: pulseTarget.orangeMin, color: "#ff5555", opacity: 0.16 }, - { - min: pulseTarget.orangeMin, - max: pulseTarget.greenMin, - color: "#ffb86c", - opacity: 0.18, - }, - { - min: pulseTarget.greenMin, - max: pulseTarget.greenMax, - color: "#50fa7b", - opacity: 0.2, - }, - { - min: pulseTarget.greenMax, - max: pulseTarget.orangeMax, - color: "#ffb86c", - opacity: 0.18, - }, - { min: pulseTarget.orangeMax, max: 220, color: "#ff5555", opacity: 0.16 }, - ].filter((band) => band.max > band.min); - const bodyFatBands = buildTrafficLightBands(bodyFatRange.min, bodyFatRange.max, { - lowerBound: 2, - upperBound: 55, - }); - return { - bpTargets, - bpSysRange, - bpDiaRange, - pulseDisplayRange: { - greenMin: pulseTarget.greenMin, - greenMax: pulseTarget.greenMax, - orangeMin: pulseTarget.orangeMin, - orangeMax: pulseTarget.orangeMax, - }, - pulseBands, - bodyFatRange, - bodyFatBands, - weightRange, - weightBands, - }; -} - /** Parse + validate the cached `dailyBriefing` block; null when unusable. */ function parseCachedBriefing( cachedText: string | null, From 3d213d1fbf2a3c4a5797f1f9977425a05015898b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 19:07:38 +0200 Subject: [PATCH 3/7] feat(insights): turn the no-provider dead-ends into guided setup Coach, the daily briefing, and the per-metric assessment tiles all default ON but need a connected AI provider that onboarding never mentions, so a fresh user hits a bare error on the headline feature. - The daily-briefing and per-metric no-provider states now explain what the AI does and why a provider is needed (your own key or a local model, data stays with you) rather than a one-line 'unavailable'. - The per-metric tile gains a direct Settings link. - The Coach 'no provider configured' turn maps to its own copy with a Settings link instead of the transient 'try again in a moment' that would 422 forever. Adds insights.noProviderAction, insights.coach.errorNoProvider and errorNoProviderAction across all six locales. --- messages/de.json | 7 +++++-- messages/en.json | 7 +++++-- messages/es.json | 7 +++++-- messages/fr.json | 7 +++++-- messages/it.json | 7 +++++-- messages/pl.json | 7 +++++-- .../__tests__/insight-status-card.test.tsx | 6 +++++- .../__tests__/message-thread.test.tsx | 9 +++++++- .../insights/coach-panel/message-thread.tsx | 21 ++++++++++++++++++- .../insights/insight-status-card.tsx | 12 +++++++++-- 10 files changed, 73 insertions(+), 17 deletions(-) diff --git a/messages/de.json b/messages/de.json index 36c227c57..dc5188ff6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1892,7 +1892,8 @@ "bmiEmptyAction": "Account-Einstellungen öffnen", "noAnalysisYet": "Noch keine Einschätzung vorhanden.", "assessmentPreparing": "Einschätzung wird vorbereitet…", - "noProviderConfigured": "Einschätzung nicht verfügbar.", + "noProviderConfigured": "Verbinde einen KI-Anbieter, um hier eine Einschätzung zu deinen Werten zu sehen.", + "noProviderAction": "Zu den KI-Einstellungen", "startAnalysis": "Einschätzung starten", "refreshAnalysis": "Einschätzung aktualisieren", "keyTakeaway": "Das Wichtigste", @@ -2007,7 +2008,7 @@ "emptyAction": "Briefing erstellen", "loadingLabel": "Briefing wird zusammengestellt", "noProviderTitle": "Kein KI-Anbieter verbunden", - "noProviderDescription": "Verbinde einen KI-Anbieter in den Einstellungen, um tägliche Briefings zu erhalten.", + "noProviderDescription": "Das Tagesbriefing fasst deine Werte in Klartext zusammen. Dafür braucht HealthLog einen KI-Anbieter — nutze deinen eigenen Schlüssel (OpenAI, Anthropic) oder ein lokales Modell. Deine Daten bleiben dabei bei dir.", "noProviderAction": "Zu den KI-Einstellungen" }, "trendsRow": { @@ -2072,6 +2073,8 @@ "refusalInjection": "In deiner Nachricht stehen Anweisungen, die meine Vorgaben überschreiben sollen. Ich bin der HealthLog-Coach und fasse ausschließlich deine eigenen Gesundheitsdaten zusammen — formuliere deine Frage bitte in diesem Rahmen neu.", "errorBudget": "Du hast das heutige Coach-Limit erreicht. Das Budget setzt sich um Mitternacht (UTC) zurück.", "errorProvider": "Der Coach konnte gerade keinen Auswertungs-Anbieter erreichen. Bitte versuche es in einem Moment erneut.", + "errorNoProvider": "Der Coach liest deine erfassten Gesundheitsdaten und beantwortet Fragen dazu (keine medizinische Beratung). Dafür braucht HealthLog einen KI-Anbieter — verbinde deinen eigenen Schlüssel oder ein lokales Modell.", + "errorNoProviderAction": "KI-Anbieter verbinden", "errorNetwork": "Keine Internetverbindung — versuche es erneut, sobald du online bist.", "prefsSaveError": "Coach-Einstellungen konnten nicht gespeichert werden. Bitte versuche es erneut.", "errorCredentialExpired": "Deine Verbindung zum KI-Anbieter ist abgelaufen. Stelle sie in den Einstellungen wieder her, damit der Coach weiterläuft.", diff --git a/messages/en.json b/messages/en.json index 18cf5182a..2cc1234f7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1892,7 +1892,8 @@ "bmiEmptyTitle": "Set your height to see BMI", "bmiEmptyDescription": "BMI needs your height. Add it under Settings → Account.", "bmiEmptyAction": "Open account settings", - "noProviderConfigured": "Assessment unavailable.", + "noProviderConfigured": "Connect an AI provider to see an assessment of your readings here.", + "noProviderAction": "Open AI settings", "startAnalysis": "Start assessment", "refreshAnalysis": "Refresh assessment", "keyTakeaway": "Key Takeaway", @@ -2007,7 +2008,7 @@ "emptyAction": "Generate briefing", "loadingLabel": "Synthesising today's briefing", "noProviderTitle": "No AI provider connected", - "noProviderDescription": "Connect an AI provider in settings to receive daily briefings.", + "noProviderDescription": "The daily briefing turns your readings into plain-language summary. HealthLog needs an AI provider for that — use your own key (OpenAI, Anthropic) or a local model. Your data stays with you.", "noProviderAction": "Open AI settings" }, "trendsRow": { @@ -2072,6 +2073,8 @@ "refusalInjection": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data — please rephrase your question in those terms.", "errorBudget": "You've reached today's Coach usage limit. The budget refreshes at midnight UTC.", "errorProvider": "The Coach could not reach an Insights provider just now. Please try again in a moment.", + "errorNoProvider": "The Coach reads your logged health data and answers questions about it (not medical advice). HealthLog needs an AI provider for that — connect your own key or a local model.", + "errorNoProviderAction": "Connect an AI provider", "errorNetwork": "No internet connection — try again once you're back online.", "prefsSaveError": "Couldn't save your Coach preferences. Please try again.", "errorCredentialExpired": "Your AI provider connection has expired. Reconnect it in Settings to keep the Coach running.", diff --git a/messages/es.json b/messages/es.json index 1b2e2c7a0..649638cc1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1892,7 +1892,8 @@ "bmiEmptyTitle": "Añade tu altura para calcular el IMC", "bmiEmptyDescription": "El IMC necesita tu altura. Guárdala en Ajustes → Cuenta.", "bmiEmptyAction": "Abrir ajustes de cuenta", - "noProviderConfigured": "Valoración no disponible.", + "noProviderConfigured": "Conecta un proveedor de IA para ver aquí una valoración de tus lecturas.", + "noProviderAction": "Abrir ajustes de IA", "startAnalysis": "Iniciar valoración", "refreshAnalysis": "Actualizar valoración", "keyTakeaway": "Lo más importante", @@ -2007,7 +2008,7 @@ "emptyAction": "Generar resumen", "loadingLabel": "Preparando el resumen", "noProviderTitle": "Ningún proveedor de IA conectado", - "noProviderDescription": "Conecta un proveedor de IA en los ajustes para recibir resúmenes diarios.", + "noProviderDescription": "El briefing diario resume tus lecturas en lenguaje claro. HealthLog necesita un proveedor de IA para ello: usa tu propia clave (OpenAI, Anthropic) o un modelo local. Tus datos siguen siendo tuyos.", "noProviderAction": "Abrir ajustes de IA" }, "trendsRow": { @@ -2072,6 +2073,8 @@ "refusalInjection": "Tu mensaje contiene instrucciones que pretenden sobrescribir mis indicaciones. Soy el coach de HealthLog y solo resumo tus propios datos de salud — reformula tu pregunta dentro de ese marco.", "errorBudget": "Has alcanzado el límite diario del coach. El cupo se reinicia a medianoche (UTC).", "errorProvider": "El coach no ha podido contactar con un proveedor de análisis ahora mismo. Inténtalo en un momento.", + "errorNoProvider": "El coach lee tus datos de salud registrados y responde preguntas sobre ellos (no es consejo médico). HealthLog necesita un proveedor de IA para ello: conecta tu propia clave o un modelo local.", + "errorNoProviderAction": "Conectar un proveedor de IA", "errorNetwork": "Sin conexión a Internet — inténtalo de nuevo cuando vuelvas a estar en línea.", "prefsSaveError": "No se pudieron guardar tus preferencias del Coach. Inténtalo de nuevo.", "errorCredentialExpired": "Tu conexión con el proveedor de IA ha caducado. Vuelve a conectarla en Ajustes para que el Coach siga funcionando.", diff --git a/messages/fr.json b/messages/fr.json index e278cc028..9fe484ea4 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1892,7 +1892,8 @@ "bmiEmptyTitle": "Ajoute ta taille pour l’IMC", "bmiEmptyDescription": "L’IMC nécessite ta taille. Renseigne-la dans Paramètres → Compte.", "bmiEmptyAction": "Ouvrir les paramètres du compte", - "noProviderConfigured": "Évaluation indisponible.", + "noProviderConfigured": "Connecte un fournisseur d'IA pour voir ici une évaluation de tes mesures.", + "noProviderAction": "Ouvrir les réglages IA", "startAnalysis": "Lancer l’évaluation", "refreshAnalysis": "Actualiser l’évaluation", "keyTakeaway": "L’essentiel", @@ -2007,7 +2008,7 @@ "emptyAction": "Générer le briefing", "loadingLabel": "Préparation du briefing", "noProviderTitle": "Aucun fournisseur d'IA connecté", - "noProviderDescription": "Connecte un fournisseur d'IA dans les réglages pour recevoir des briefings quotidiens.", + "noProviderDescription": "Le briefing quotidien résume tes mesures en langage clair. HealthLog a besoin d'un fournisseur d'IA pour cela : utilise ta propre clé (OpenAI, Anthropic) ou un modèle local. Tes données restent chez toi.", "noProviderAction": "Ouvrir les réglages IA" }, "trendsRow": { @@ -2072,6 +2073,8 @@ "refusalInjection": "Ton message contient des instructions qui cherchent à remplacer mes consignes. Je suis le coach HealthLog et je ne résume que tes propres données de santé — reformule ta question dans ce cadre.", "errorBudget": "Tu as atteint la limite quotidienne du coach. Le quota se réinitialise à minuit (UTC).", "errorProvider": "Le coach n’a pas pu joindre un fournisseur d’analyse maintenant. Réessaie dans un instant.", + "errorNoProvider": "Le coach lit tes données de santé enregistrées et répond aux questions à leur sujet (pas un avis médical). HealthLog a besoin d'un fournisseur d'IA pour cela : connecte ta propre clé ou un modèle local.", + "errorNoProviderAction": "Connecter un fournisseur d'IA", "errorNetwork": "Pas de connexion Internet — réessaie une fois reconnecté.", "prefsSaveError": "Impossible d’enregistrer vos préférences du Coach. Réessayez.", "errorCredentialExpired": "Ta connexion au fournisseur d'IA a expiré. Reconnecte-la dans les Réglages pour que le Coach continue de fonctionner.", diff --git a/messages/it.json b/messages/it.json index e5fb79eb1..a50e007a6 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1892,7 +1892,8 @@ "bmiEmptyTitle": "Aggiungi l’altezza per il BMI", "bmiEmptyDescription": "Per il BMI serve la tua altezza. Salvala in Impostazioni → Account.", "bmiEmptyAction": "Apri impostazioni account", - "noProviderConfigured": "Valutazione non disponibile.", + "noProviderConfigured": "Collega un provider IA per vedere qui una valutazione delle tue misurazioni.", + "noProviderAction": "Apri impostazioni IA", "startAnalysis": "Avvia valutazione", "refreshAnalysis": "Aggiorna valutazione", "keyTakeaway": "Ciò che conta", @@ -2007,7 +2008,7 @@ "emptyAction": "Genera briefing", "loadingLabel": "Preparazione del briefing", "noProviderTitle": "Nessun provider IA collegato", - "noProviderDescription": "Collega un provider IA nelle impostazioni per ricevere briefing giornalieri.", + "noProviderDescription": "Il briefing giornaliero riassume le tue misurazioni in linguaggio chiaro. HealthLog ha bisogno di un provider IA per questo: usa la tua chiave (OpenAI, Anthropic) o un modello locale. I tuoi dati restano tuoi.", "noProviderAction": "Apri impostazioni IA" }, "trendsRow": { @@ -2072,6 +2073,8 @@ "refusalInjection": "Nel tuo messaggio ci sono istruzioni che cercano di sovrascrivere le mie indicazioni. Sono il coach di HealthLog e riassumo solo i tuoi dati di salute — riformula la domanda in questo perimetro.", "errorBudget": "Hai raggiunto il limite giornaliero del coach. Il budget si reimposta a mezzanotte (UTC).", "errorProvider": "Il coach non è riuscito a contattare un provider di analisi ora. Riprova fra poco.", + "errorNoProvider": "Il coach legge i tuoi dati di salute registrati e risponde a domande su di essi (non è consulenza medica). HealthLog ha bisogno di un provider IA per questo: collega la tua chiave o un modello locale.", + "errorNoProviderAction": "Collega un provider IA", "errorNetwork": "Nessuna connessione Internet — riprova quando sei di nuovo online.", "prefsSaveError": "Impossibile salvare le preferenze del Coach. Riprova.", "errorCredentialExpired": "La connessione al provider di IA è scaduta. Riconnettila nelle Impostazioni per mantenere attivo il Coach.", diff --git a/messages/pl.json b/messages/pl.json index f09755820..b3353ef57 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1892,7 +1892,8 @@ "bmiEmptyTitle": "Dodaj wzrost, aby obliczyć BMI", "bmiEmptyDescription": "BMI wymaga twojego wzrostu. Zapisz go w Ustawieniach → Konto.", "bmiEmptyAction": "Otwórz ustawienia konta", - "noProviderConfigured": "Ocena niedostępna.", + "noProviderConfigured": "Połącz dostawcę AI, aby zobaczyć tutaj ocenę swoich pomiarów.", + "noProviderAction": "Otwórz ustawienia AI", "startAnalysis": "Uruchom ocenę", "refreshAnalysis": "Odśwież ocenę", "keyTakeaway": "Najważniejsze", @@ -2007,7 +2008,7 @@ "emptyAction": "Wygeneruj briefing", "loadingLabel": "Przygotowanie briefingu", "noProviderTitle": "Brak połączonego dostawcy AI", - "noProviderDescription": "Połącz dostawcę AI w ustawieniach, aby otrzymywać codzienne briefingi.", + "noProviderDescription": "Codzienny briefing podsumowuje Twoje pomiary prostym językiem. HealthLog potrzebuje do tego dostawcy AI — użyj własnego klucza (OpenAI, Anthropic) lub modelu lokalnego. Twoje dane pozostają u Ciebie.", "noProviderAction": "Otwórz ustawienia AI" }, "trendsRow": { @@ -2072,6 +2073,8 @@ "refusalInjection": "Twoja wiadomość zawiera instrukcje, które próbują nadpisać moje wytyczne. Jestem coachem HealthLog i podsumowuję wyłącznie twoje własne dane zdrowotne — przeformułuj pytanie w tych ramach.", "errorBudget": "Osiągnięto dzienny limit coacha. Limit resetuje się o północy (UTC).", "errorProvider": "Coach nie mógł teraz skontaktować się z dostawcą analizy. Spróbuj za chwilę.", + "errorNoProvider": "Coach czyta Twoje zapisane dane zdrowotne i odpowiada na pytania o nie (to nie porada medyczna). HealthLog potrzebuje do tego dostawcy AI — połącz własny klucz lub model lokalny.", + "errorNoProviderAction": "Połącz dostawcę AI", "errorNetwork": "Brak połączenia z Internetem — spróbuj ponownie, gdy wrócisz online.", "prefsSaveError": "Nie udało się zapisać ustawień Coacha. Spróbuj ponownie.", "errorCredentialExpired": "Połączenie z dostawcą AI wygasło. Połącz je ponownie w Ustawieniach, aby Coach działał dalej.", diff --git a/src/components/insights/__tests__/insight-status-card.test.tsx b/src/components/insights/__tests__/insight-status-card.test.tsx index 6ecabe3fc..d55bf0954 100644 --- a/src/components/insights/__tests__/insight-status-card.test.tsx +++ b/src/components/insights/__tests__/insight-status-card.test.tsx @@ -80,7 +80,11 @@ describe("", () => { text="ignored when hasProvider is false" />, ); - expect(html).toContain("Assessment unavailable."); + // v1.18.6 — the no-provider tile is a guided-setup explainer + a + // Settings link, not a bare "unavailable" line. + expect(html).toContain("Connect an AI provider"); + expect(html).toContain("Open AI settings"); + expect(html).toContain("/settings/ai"); expect(html).not.toContain("ignored when hasProvider is false"); }); diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index fc6cea16c..e09507a2f 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -662,13 +662,20 @@ describe("errorCodeToI18nKey", () => { for (const code of [ "coach.provider.unavailable", "coach.provider.empty", - "coach.provider.none", "coach.stream", ]) { expect(errorCodeToI18nKey(code)).toBe("insights.coach.errorProvider"); } }); + // v1.18.6 — a "no provider configured anywhere" turn is a setup gap, + // not a transient failure, so it carries its own guided-setup copy. + it("maps the no-provider case to its dedicated guided-setup copy", () => { + expect(errorCodeToI18nKey("coach.provider.none")).toBe( + "insights.coach.errorNoProvider", + ); + }); + // v1.4.43 QoL (M6) — `coach.network` no longer collapses to the // generic provider copy; the user needs the actionable offline hint. it("maps coach.network to the dedicated offline key", () => { diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index 59f0755e2..75a632f59 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -1,6 +1,7 @@ "use client"; import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react"; +import Link from "next/link"; import { useMutation } from "@tanstack/react-query"; import { toast } from "sonner"; import { @@ -168,9 +169,14 @@ export function errorCodeToI18nKey(code: string): string { // (auth-class failure). The next action is "reconnect", not "try // again later", so it carries its own copy pointing at Settings. return "insights.coach.errorCredentialExpired"; + case "coach.provider.none": + // v1.18.6 — no AI provider is configured anywhere, so "try again + // in a moment" is the wrong instruction (it will 422 forever). Send + // the user to guided setup with a one-line explainer + a Settings + // link rendered alongside the bubble copy. + return "insights.coach.errorNoProvider"; case "coach.provider.unavailable": case "coach.provider.empty": - case "coach.provider.none": case "coach.stream": return "insights.coach.errorProvider"; default: @@ -613,6 +619,19 @@ function ChatBubble({ {safeError && content && (

{safeError}

)} + {/* v1.18.6 — a "no provider configured anywhere" turn is a + setup gap, not a transient failure: surface a direct link to + Settings → AI so the Coach guides the user into BYOK / local + setup instead of inviting an endless retry. */} + {errorCode === "coach.provider.none" && ( + + {t("insights.coach.errorNoProviderAction")} + + )} {/* v1.12.0 — collapse the whole provenance block ("what was included") behind one disclosure, collapsed by default. The source chips used to render fully expanded above the diff --git a/src/components/insights/insight-status-card.tsx b/src/components/insights/insight-status-card.tsx index b0a5b2dfc..a4682ed07 100644 --- a/src/components/insights/insight-status-card.tsx +++ b/src/components/insights/insight-status-card.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useRef, useState } from "react"; +import Link from "next/link"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; @@ -144,14 +145,21 @@ export function InsightStatusCard({ if (!hasProvider) { return ( - + - +

{t("insights.noProviderConfigured")}

+ + {t("insights.noProviderAction")} +
); From cd816607e46e8f9b2a8f290d59a79f8d3af20ebf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 19:09:12 +0200 Subject: [PATCH 4/7] feat(onboarding): surface AI-provider setup on the done step AI Insights, Coach and the daily briefing are default-ON but need a BYOK or local provider that onboarding never mentioned, so a fresh user met the flagship feature as a cold error. Add an optional, skippable card to the onboarding done step that explains what the AI does (plain-language summaries of your readings, data stays with you) and deep-links to Settings -> AI. Adds onboarding.done.aiTitle/aiBody/aiCta across all six locales. --- messages/de.json | 5 +++- messages/en.json | 5 +++- messages/es.json | 5 +++- messages/fr.json | 5 +++- messages/it.json | 5 +++- messages/pl.json | 5 +++- src/components/onboarding/DoneScreen.tsx | 35 +++++++++++++++++++++++- 7 files changed, 58 insertions(+), 7 deletions(-) diff --git a/messages/de.json b/messages/de.json index dc5188ff6..ecf2d8338 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3626,7 +3626,10 @@ "learning": "Manche Einblicke – Glukose, Schlafrhythmus, Blutdrucktrends, Zyklus – schärfen sich in den ersten ein bis zwei Wochen, sobald sich deine Daten ansammeln. Leere Kacheln heißen jetzt nur, dass wir deine Basislinie noch lernen.", "connectCta": "Gerät verbinden", "logCta": "Ersten Wert eintragen", - "importCta": "Bestehende Daten importieren" + "importCta": "Bestehende Daten importieren", + "aiTitle": "KI-Einblicke aktivieren (optional)", + "aiBody": "Coach, Tagesbriefing und Einschätzungen fassen deine Werte in Klartext zusammen. Dafür verbindest du deinen eigenen KI-Schlüssel (OpenAI, Anthropic) oder ein lokales Modell — deine Daten bleiben bei dir.", + "aiCta": "KI-Anbieter verbinden" }, "welcomeBack": { "title": "Willkommen zurück", diff --git a/messages/en.json b/messages/en.json index 2cc1234f7..63d8a76e0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3626,7 +3626,10 @@ "learning": "Some insights — glucose, sleep rhythm, blood-pressure trends, cycle — sharpen over the first week or two as your data builds up. Empty tiles now just mean we're still learning your baseline.", "connectCta": "Connect a device", "logCta": "Log your first measurement", - "importCta": "Import existing history" + "importCta": "Import existing history", + "aiTitle": "Turn on AI Insights (optional)", + "aiBody": "Coach, the daily briefing and assessments turn your readings into plain language. For that you connect your own AI key (OpenAI, Anthropic) or a local model — your data stays with you.", + "aiCta": "Connect an AI provider" }, "welcomeBack": { "title": "Welcome back", diff --git a/messages/es.json b/messages/es.json index 649638cc1..fe84dfe60 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3626,7 +3626,10 @@ "learning": "Algunas conclusiones —glucosa, ritmo de sueño, tendencias de presión arterial, ciclo— se afinan durante la primera semana o dos a medida que se acumulan tus datos. Las casillas vacías ahora solo significan que aún estamos aprendiendo tu línea base.", "connectCta": "Conectar un dispositivo", "logCta": "Registra tu primera medición", - "importCta": "Importar historial existente" + "importCta": "Importar historial existente", + "aiTitle": "Activar Insights con IA (opcional)", + "aiBody": "El coach, el briefing diario y las valoraciones convierten tus lecturas en lenguaje claro. Para ello conectas tu propia clave de IA (OpenAI, Anthropic) o un modelo local: tus datos siguen siendo tuyos.", + "aiCta": "Conectar un proveedor de IA" }, "welcomeBack": { "title": "Bienvenido de nuevo", diff --git a/messages/fr.json b/messages/fr.json index 9fe484ea4..fd10e6d92 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3626,7 +3626,10 @@ "learning": "Certaines analyses — glycémie, rythme de sommeil, tendances de tension, cycle — s'affinent au cours de la première semaine ou deux à mesure que vos données s'accumulent. Des tuiles vides signifient simplement que nous apprenons encore votre référence.", "connectCta": "Connecter un appareil", "logCta": "Enregistrez votre première mesure", - "importCta": "Importer un historique existant" + "importCta": "Importer un historique existant", + "aiTitle": "Activer les Insights IA (facultatif)", + "aiBody": "Le coach, le briefing quotidien et les évaluations traduisent tes mesures en langage clair. Pour cela, connecte ta propre clé IA (OpenAI, Anthropic) ou un modèle local — tes données restent chez toi.", + "aiCta": "Connecter un fournisseur d'IA" }, "welcomeBack": { "title": "Bon retour", diff --git a/messages/it.json b/messages/it.json index a50e007a6..841d260b7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3626,7 +3626,10 @@ "learning": "Alcune analisi — glicemia, ritmo del sonno, tendenze della pressione, ciclo — si affinano nella prima settimana o due man mano che i tuoi dati si accumulano. Le caselle vuote ora indicano solo che stiamo ancora imparando il tuo riferimento.", "connectCta": "Collega un dispositivo", "logCta": "Registra la tua prima misurazione", - "importCta": "Importa la cronologia esistente" + "importCta": "Importa la cronologia esistente", + "aiTitle": "Attiva gli Insight IA (facoltativo)", + "aiBody": "Il coach, il briefing giornaliero e le valutazioni trasformano le tue misurazioni in linguaggio chiaro. Per farlo colleghi la tua chiave IA (OpenAI, Anthropic) o un modello locale: i tuoi dati restano tuoi.", + "aiCta": "Collega un provider IA" }, "welcomeBack": { "title": "Bentornato", diff --git a/messages/pl.json b/messages/pl.json index b3353ef57..29520337d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3626,7 +3626,10 @@ "learning": "Niektóre wnioski — glukoza, rytm snu, trendy ciśnienia, cykl — wyostrzają się w ciągu pierwszego tygodnia lub dwóch, w miarę gromadzenia danych. Puste kafelki oznaczają teraz tylko, że wciąż poznajemy Twój punkt odniesienia.", "connectCta": "Połącz urządzenie", "logCta": "Zapisz pierwszy pomiar", - "importCta": "Importuj istniejącą historię" + "importCta": "Importuj istniejącą historię", + "aiTitle": "Włącz Insighty AI (opcjonalnie)", + "aiBody": "Coach, codzienny briefing i oceny zamieniają Twoje pomiary w prosty język. W tym celu łączysz własny klucz AI (OpenAI, Anthropic) lub model lokalny — Twoje dane pozostają u Ciebie.", + "aiCta": "Połącz dostawcę AI" }, "welcomeBack": { "title": "Witaj ponownie", diff --git a/src/components/onboarding/DoneScreen.tsx b/src/components/onboarding/DoneScreen.tsx index 9b924a123..3c6d7e01e 100644 --- a/src/components/onboarding/DoneScreen.tsx +++ b/src/components/onboarding/DoneScreen.tsx @@ -1,7 +1,13 @@ "use client"; import Link from "next/link"; -import { CheckCircle2, FileUp, PlusCircle, Plug } from "lucide-react"; +import { + CheckCircle2, + FileUp, + PlusCircle, + Plug, + Sparkles, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { useTranslations } from "@/lib/i18n/context"; @@ -51,6 +57,33 @@ export function DoneScreen() {

+ {/* v1.18.6 — surface the AI Insights / Coach / briefing setup at + the highest-intent moment. The flagship feature needs a BYOK or + local provider that the rest of onboarding never mentions, so a + fresh user otherwise discovers it as a cold error. This card is + skippable by construction — it is an optional deep-link, not a + gate. */} +
+ +

+ {t("onboarding.done.aiTitle")} +

+

+ {t("onboarding.done.aiBody")} +

+ + {t("onboarding.done.aiCta")} + +
+