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. */}
+