From 20fc03bd613e8d2e1df667f8245bbe1ac8d33684 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 21:43:58 +0100 Subject: [PATCH 01/13] perf(insights): read the coach strip's pattern scan once per record Every metric page asked `/api/insights/coach-read`, and every call ran the full 180-day discovery scan over every channel, plus the emerging, lab and coincident passes, to pick the one driver whose outcome is the page's metric. The scan does not depend on the metric, so each page in a row paid over a second for the same answer and the strip stayed a skeleton long after the chart had drawn. The strip now reads the drivers through an analytics cell keyed on the record, the language and the switched-off modules, the same contract `/api/insights/correlations` uses: every measurement, mood, medication, illness, custom-metric and pattern write sweeps the record's cells. A failed scan is not kept. The band and the latest reading stay per call. --- .../coach-read-drivers-cache.test.ts | 117 ++++++++++++++++++ src/lib/insights/derived/coach-read.ts | 50 ++++++-- 2 files changed, 157 insertions(+), 10 deletions(-) create mode 100644 src/lib/insights/derived/__tests__/coach-read-drivers-cache.test.ts diff --git a/src/lib/insights/derived/__tests__/coach-read-drivers-cache.test.ts b/src/lib/insights/derived/__tests__/coach-read-drivers-cache.test.ts new file mode 100644 index 000000000..b4c2a1ed4 --- /dev/null +++ b/src/lib/insights/derived/__tests__/coach-read-drivers-cache.test.ts @@ -0,0 +1,117 @@ +/** + * The "Coach read" strip reads its pattern line from one cached scan per + * record and language, not one scan per metric page: the scan covers every + * channel and does not depend on the metric. A failed scan is not cached, a + * write that sweeps the record's analytics cells forces a new one, and a + * switched-off module reads under its own cell. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const readCoachCorrelations = vi.fn(); +const resolveModuleMap = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { + findMany: async () => [], + findFirst: async () => null, + }, + }, +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/modules/gate", () => ({ + resolveModuleMap: (userId: string) => resolveModuleMap(userId), +})); +vi.mock("@/lib/insights/derived/baseline", async (importOriginal) => ({ + ...(await importOriginal()), + loadBaselineProfile: async () => ({ ageYears: 40, sex: "MALE" }), + // The band is not what this file is about: no band, the strip says + // "still learning" and only the pattern line varies. + computeVitalsBaseline: async () => ({ status: "insufficient" }), +})); +vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({ + readCoachCorrelations: (userId: string, locale: string) => + readCoachCorrelations(userId, locale), +})); + +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import { invalidateUserMood } from "@/lib/cache/invalidate"; +import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read"; + +const SLEEP_DRIVER = { + behaviour: "symptom severity", + outcome: "sleep duration", + direction: "lower" as const, + lagDays: 1, + n: 40, + r: -0.6, + note: "A pattern to watch, not a cause.", +}; +const PULSE_DRIVER = { ...SLEEP_DRIVER, outcome: "pulse" }; + +const strip = (type: "SLEEP_DURATION" | "PULSE", locale: "en" | "de" = "en") => + buildCoachReadStrip("u1", type, locale, { tz: "UTC" }); + +beforeEach(() => { + __resetAllCachesForTests(); + readCoachCorrelations.mockReset().mockResolvedValue({ + present: true, + drivers: [SLEEP_DRIVER, PULSE_DRIVER], + }); + resolveModuleMap.mockReset().mockResolvedValue({}); +}); + +describe("coach read — the pattern scan behind the strip", () => { + it("scans once for two metric pages and picks each page's own driver", async () => { + const sleep = await strip("SLEEP_DURATION"); + const pulse = await strip("PULSE"); + + expect(readCoachCorrelations).toHaveBeenCalledTimes(1); + expect(sleep.driver?.outcome).toBe("sleep duration"); + expect(pulse.driver?.outcome).toBe("pulse"); + }); + + it("scans again per language, since the note is a finished sentence", async () => { + await strip("SLEEP_DURATION", "en"); + await strip("SLEEP_DURATION", "de"); + + expect(readCoachCorrelations).toHaveBeenCalledTimes(2); + expect(readCoachCorrelations).toHaveBeenLastCalledWith("u1", "de"); + }); + + it("does not keep a failed scan", async () => { + readCoachCorrelations.mockResolvedValueOnce({ + present: false, + reason: "retrieval_failed", + }); + + const failed = await strip("SLEEP_DURATION"); + const retried = await strip("SLEEP_DURATION"); + + expect(failed.driver).toBeNull(); + expect(retried.driver?.outcome).toBe("sleep duration"); + expect(readCoachCorrelations).toHaveBeenCalledTimes(2); + }); + + it("scans again after a write sweeps the record's analytics cells", async () => { + await strip("SLEEP_DURATION"); + invalidateUserMood("u1"); + await strip("SLEEP_DURATION"); + + expect(readCoachCorrelations).toHaveBeenCalledTimes(2); + }); + + it("reads a switched-off module under its own cell", async () => { + await strip("SLEEP_DURATION"); + resolveModuleMap.mockResolvedValue({ insights: false }); + readCoachCorrelations.mockResolvedValue({ + present: false, + reason: "module_disabled", + }); + + const off = await strip("SLEEP_DURATION"); + + expect(readCoachCorrelations).toHaveBeenCalledTimes(2); + expect(off.driver).toBeNull(); + }); +}); diff --git a/src/lib/insights/derived/coach-read.ts b/src/lib/insights/derived/coach-read.ts index 55850a81c..0b152516f 100644 --- a/src/lib/insights/derived/coach-read.ts +++ b/src/lib/insights/derived/coach-read.ts @@ -25,8 +25,12 @@ * component and the unit test can share them without pulling Prisma. */ import type { MeasurementType } from "@/generated/prisma/client"; +import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { prisma } from "@/lib/db"; import type { Locale } from "@/lib/i18n/config"; +import { annotate } from "@/lib/logging/context"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import { MODULE_KEYS } from "@/lib/modules/registry"; import { loadBaselineProfile, computeVitalsBaseline, @@ -42,7 +46,10 @@ import { isFromToday, } from "@/lib/insights/measurement-freshness"; import { userDayKey } from "@/lib/tz/format"; -import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read"; +import { + readCoachCorrelations, + type CoachCorrelationDriver, +} from "@/lib/ai/coach/tools/correlations-read"; import { humaniseType, placeAgainstBand, @@ -88,6 +95,34 @@ function latestDay( }; } +/** + * The discovered drivers, through the analytics cache. The scan covers every + * channel over 180 days and does not depend on the metric, so without the cell + * each metric page paid the whole scan again for the one line it picks. Same + * cell contract as `/api/insights/correlations`: measurement, mood, medication, + * illness, custom-metric and pattern writes sweep the `${userId}|` prefix, and + * the switched-off modules are part of the key. A failed read is not cached. + */ +async function readDrivers( + userId: string, + locale: Locale, +): Promise { + const modules = await resolveModuleMap(userId); + const modulesOff = MODULE_KEYS.filter((key) => modules[key] === false); + return cachedSwr( + caches.analytics as ServerCache, + `${userId}|coach-read-drivers|${locale}|off:${modulesOff.join(",")}`, + async () => { + const result = await readCoachCorrelations(userId, locale); + if (result.reason === "retrieval_failed") { + throw new Error("coach read correlations failed"); + } + return result.drivers ?? null; + }, + annotate, + ); +} + /** Trailing window the band engine reads; the day-mean placement matches it. */ const BAND_WINDOW_DAYS = 30; @@ -165,13 +200,11 @@ export async function buildCoachReadStrip( const now = opts.now ?? new Date(); const profile = await loadBaselineProfile(prisma, userId); - const [baselineDerived, latest, correlations] = await Promise.all([ + const [baselineDerived, latest, drivers] = await Promise.all([ computeVitalsBaseline(userId, profile, { type, now }), readLatestValue(userId, type), // Line 2 is best-effort: a correlation failure must never sink the band. - readCoachCorrelations(userId, locale).catch( - () => ({ present: false }) as const, - ), + readDrivers(userId, locale).catch(() => null), ]); let baseline: CoachReadBaseline | null = null; @@ -207,11 +240,8 @@ export async function buildCoachReadStrip( } const driver = ((): CoachReadDriver | null => { - if (!("drivers" in correlations) || !correlations.drivers) return null; - const picked = pickDriverForMetric( - correlations.drivers, - humaniseType(type), - ); + if (!drivers) return null; + const picked = pickDriverForMetric(drivers, humaniseType(type)); if (!picked) return null; return { note: picked.note, From 9b2c68fbe54720627836d4cd1eb4ea25545635f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 21:46:09 +0100 Subject: [PATCH 02/13] perf(insights): keep the health status answer in the analytics cell `/api/insights/health-status` recomputed the coincident deviation and the 180-day changepoint scan over every vital on each open of Insights, the only read on that page that stayed slow when warm, for a body of a few hundred bytes. It now goes through the same analytics cell its sibling reads use. Measurement and mood writes sweep the record's cells; the reader's day and the switched-off modules are part of the key, so a new day or a module switch never serves the other answer. --- .../health-status/__tests__/route.test.ts | 113 ++++++++++++++++++ src/app/api/insights/health-status/route.ts | 64 ++++++---- 2 files changed, 156 insertions(+), 21 deletions(-) create mode 100644 src/app/api/insights/health-status/__tests__/route.test.ts diff --git a/src/app/api/insights/health-status/__tests__/route.test.ts b/src/app/api/insights/health-status/__tests__/route.test.ts new file mode 100644 index 000000000..4f2b428e1 --- /dev/null +++ b/src/app/api/insights/health-status/__tests__/route.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * The health status card reads two engines that walk every vital over up to + * 180 days. The answer is kept in the record's analytics cell: a second open + * of Insights serves it, and a write that sweeps the record's cells, or a + * module switch, computes it again. + */ + +const computeCoincidentDeviation = vi.fn(); +const buildExtendedChangepointSignals = vi.fn(); +const resolveModuleMap = vi.fn(); + +vi.mock("@/lib/insights/derived/coincident-deviation", () => ({ + computeCoincidentDeviation: (...args: unknown[]) => + computeCoincidentDeviation(...args), +})); +vi.mock("@/lib/insights/derived/changepoint", () => ({ + buildExtendedChangepointSignals: (...args: unknown[]) => + buildExtendedChangepointSignals(...args), +})); +vi.mock("@/lib/insights/derived", () => ({ + loadBaselineProfile: vi.fn().mockResolvedValue({ ageYears: 40 }), +})); +vi.mock("@/lib/db", () => ({ + prisma: { + auditLog: { create: vi.fn() }, + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + resolveModuleMap: (userId: string) => resolveModuleMap(userId), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkAnalyticsReadRateLimit: vi.fn(), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; + +const call = () => GET(); + +beforeEach(() => { + __resetAllCachesForTests(); + vi.mocked(getSession).mockResolvedValue({ + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "testuser", + role: "USER", + timezone: "Europe/Berlin", + }, + } as unknown as Awaited>); + vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({ + allowed: true, + } as unknown as Awaited>); + computeCoincidentDeviation + .mockReset() + .mockResolvedValue({ status: "insufficient" }); + buildExtendedChangepointSignals.mockReset().mockResolvedValue([]); + resolveModuleMap.mockReset().mockResolvedValue({}); +}); + +describe("GET /api/insights/health-status — the cached answer", () => { + it("computes once for two opens", async () => { + const first = await call(); + const second = await call(); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(computeCoincidentDeviation).toHaveBeenCalledTimes(1); + expect(buildExtendedChangepointSignals).toHaveBeenCalledTimes(1); + expect((await second.json()).data).toEqual((await first.json()).data); + }); + + it("computes again after a measurement write", async () => { + await call(); + invalidateUserMeasurements("user-1", { evict: true }); + await call(); + + expect(computeCoincidentDeviation).toHaveBeenCalledTimes(2); + }); + + it("computes again when a module is switched off", async () => { + await call(); + resolveModuleMap.mockResolvedValue({ sleep: false }); + await call(); + + expect(buildExtendedChangepointSignals).toHaveBeenCalledTimes(2); + expect(buildExtendedChangepointSignals).toHaveBeenLastCalledWith( + "user-1", + expect.any(Date), + { sleep: false }, + ); + }); +}); diff --git a/src/app/api/insights/health-status/route.ts b/src/app/api/insights/health-status/route.ts index 601c472ee..9c96fb33c 100644 --- a/src/app/api/insights/health-status/route.ts +++ b/src/app/api/insights/health-status/route.ts @@ -24,8 +24,10 @@ import { prisma } from "@/lib/db"; import { loadBaselineProfile } from "@/lib/insights/derived"; import { computeCoincidentDeviation } from "@/lib/insights/derived/coincident-deviation"; import { buildExtendedChangepointSignals } from "@/lib/insights/derived/changepoint"; -import { resolveModuleMap } from "@/lib/modules/gate"; +import { MODULE_KEYS, resolveModuleMap } from "@/lib/modules/gate"; import { summariseHealthStatus } from "@/lib/insights/health-status"; +import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; export const dynamic = "force-dynamic"; @@ -39,36 +41,56 @@ export const GET = apiHandler(async () => { return apiError("Too many analytics requests. Please retry later.", 429); } + // Both engines walk every vital over up to 180 days, so the answer goes + // through the analytics cell its sibling reads use. Measurement and mood + // writes sweep the record's cells; the reader's day and the switched-off + // modules are part of the key, so a new day or a module switch never serves + // the other answer. + const modules = await resolveModuleMap(user.id); + const modulesOff = MODULE_KEYS.filter((key) => modules[key] === false); const now = new Date(); - const profile = await loadBaselineProfile(prisma, user.id); - - const coincident = await computeCoincidentDeviation(user.id, profile, { - tz: user.timezone, - now, - }); - // v1.44 — the wider scan: every vital with a stable daily level over 180 - // days, a metric whose module is off left unread. - const shifts = await buildExtendedChangepointSignals( - user.id, - now, - await resolveModuleMap(user.id), + const day = userDayKey(now, user.timezone ?? DEFAULT_TIMEZONE); + const body = await cachedSwr( + caches.analytics as ServerCache, + `${user.id}|health-status|${day}|off:${modulesOff.join(",")}`, + () => buildHealthStatus(user.id, user.timezone, now, modules), + annotate, ); - const vitals = coincident.status === "ok" ? coincident.value.vitals : []; - const summary = summariseHealthStatus(vitals, shifts); - annotate({ action: { name: "insights.health-status.read" }, meta: { - deviations: summary.deviations.length, - shifts: summary.shifts.length, + deviations: body.deviations.length, + shifts: body.shifts.length, }, }); - return apiSuccess({ + return apiSuccess(body); +}); + +type HealthStatusBody = Awaited>; + +async function buildHealthStatus( + userId: string, + timezone: string | undefined, + now: Date, + modules: Awaited>, +) { + const profile = await loadBaselineProfile(prisma, userId); + const coincident = await computeCoincidentDeviation(userId, profile, { + tz: timezone, + now, + }); + // v1.44 — the wider scan: every vital with a stable daily level over 180 + // days, a metric whose module is off left unread. + const shifts = await buildExtendedChangepointSignals(userId, now, modules); + + const vitals = coincident.status === "ok" ? coincident.value.vitals : []; + const summary = summariseHealthStatus(vitals, shifts); + return { present: summary.present, deviations: summary.deviations, shifts: summary.shifts, generatedAt: now.toISOString(), - }); -}); + }; +} From a6fdd48058626aa64d1e112817c5dca1cc51d3d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 21:47:24 +0100 Subject: [PATCH 03/13] fix(pwa): pause the version check while the tab is hidden The version poller asked `/api/version` every 60 s from a plain timer, also in a background tab or a minimised window, for as long as the browser let timers run. It was the largest standing poll and the only one that kept going while hidden, although the poll notes describe it as the same shape as the others. A tick now asks only while the tab is in view, and a tab that comes back after a full interval away checks once at once, so a release still surfaces on return. --- .../__tests__/version-poller.test.ts | 23 +++++++++- src/components/version-poller.tsx | 45 ++++++++++++++++--- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/src/components/__tests__/version-poller.test.ts b/src/components/__tests__/version-poller.test.ts index 14b0a24da..c9a25defa 100644 --- a/src/components/__tests__/version-poller.test.ts +++ b/src/components/__tests__/version-poller.test.ts @@ -15,7 +15,11 @@ import { describe, expect, it } from "vitest"; -import { cachesToEvict, resolveVersionPollDecision } from "../version-poller"; +import { + cachesToEvict, + resolveVersionPollDecision, + versionCheckDueOnReturn, +} from "../version-poller"; describe("resolveVersionPollDecision", () => { it("is up-to-date when live matches the shell", () => { @@ -172,3 +176,20 @@ describe("cachesToEvict", () => { expect(cachesToEvict(keys, "1.43.0")).not.toContain("another-app-cache"); }); }); + +describe("versionCheckDueOnReturn", () => { + const NOW = 1_000_000_000; + + it("asks at once when the tab comes back after a full interval away", () => { + expect(versionCheckDueOnReturn("visible", NOW - 60_000, NOW)).toBe(true); + expect(versionCheckDueOnReturn("visible", 0, NOW)).toBe(true); + }); + + it("does not ask again on a quick switch back", () => { + expect(versionCheckDueOnReturn("visible", NOW - 5_000, NOW)).toBe(false); + }); + + it("never asks while the tab is hidden", () => { + expect(versionCheckDueOnReturn("hidden", 0, NOW)).toBe(false); + }); +}); diff --git a/src/components/version-poller.tsx b/src/components/version-poller.tsx index 6252509fb..3d0527b4c 100644 --- a/src/components/version-poller.tsx +++ b/src/components/version-poller.tsx @@ -19,7 +19,8 @@ import { HEALTHLOG_CACHE_NAME_RE } from "@/lib/pwa/sw-cache-policy"; * the user trips the error — typically in the middle of a * navigation flow. * - * This component runs the check pro-actively. Every 60 s it fetches + * This component runs the check pro-actively. Every 60 s while the tab + * is in view (and once on coming back to it) it fetches * `/api/version` and compares the live version string against * `NEXT_PUBLIC_APP_VERSION` (injected from `package.json` at build * time by `next.config.ts`). When the live version moves ahead of @@ -93,6 +94,19 @@ async function fetchLiveVersion(signal: AbortSignal): Promise { } } +/** + * Whether a tab that comes back into view checks the version now. A hidden + * tab skips its ticks, so the first look after a full interval away is due at + * once; a quick switch back and forth is not a reason to ask again. + */ +export function versionCheckDueOnReturn( + visibility: DocumentVisibilityState, + lastCheckedAt: number, + now: number, +): boolean { + return visibility === "visible" && now - lastCheckedAt >= POLL_INTERVAL_MS; +} + // In-memory mirror of the session guard: under strict-privacy modes // sessionStorage throws, and without a fallback a persistent // shell/live mismatch (misconfigured build) would reload every poll. @@ -165,8 +179,10 @@ export function VersionPoller(): null { if (typeof window === "undefined") return; const controller = new AbortController(); + let lastCheckedAt = 0; async function checkOnce(): Promise { + lastCheckedAt = Date.now(); const live = await fetchLiveVersion(controller.signal); let lastReloadedFor: string | null = null; try { @@ -201,16 +217,31 @@ export function VersionPoller(): null { } // First check 5 s after mount — gives the app a chance to settle - // before we surface anything. Then on the recurring interval. - const initial = window.setTimeout(() => void checkOnce(), 5_000); - const interval = window.setInterval( - () => void checkOnce(), - POLL_INTERVAL_MS, - ); + // before we surface anything. Then on the recurring interval, but only + // while the tab is in view: a background tab asks nothing and checks + // once when it comes back. + const tick = () => { + if (document.visibilityState === "visible") void checkOnce(); + }; + const onVisibilityChange = () => { + if ( + versionCheckDueOnReturn( + document.visibilityState, + lastCheckedAt, + Date.now(), + ) + ) { + void checkOnce(); + } + }; + const initial = window.setTimeout(tick, 5_000); + const interval = window.setInterval(tick, POLL_INTERVAL_MS); + document.addEventListener("visibilitychange", onVisibilityChange); return () => { window.clearTimeout(initial); window.clearInterval(interval); + document.removeEventListener("visibilitychange", onVisibilityChange); controller.abort(); }; }, [t]); From d310058bc59c531c8153b83d3f6ee8c4ad0df97e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 21:48:15 +0100 Subject: [PATCH 04/13] fix(admin): read the AI quality summary once on the Coach page `/admin/coach` mounts the AI quality card and the Coach feedback card, and both read `GET /api/admin/ai-quality` with the same unwrap, under two different query keys, so every open sent the request twice. The feedback card now reads the quality card's cell. --- .../__tests__/coach-feedback-section.test.tsx | 23 +++++++++++++++++++ .../admin/coach-feedback-section.tsx | 4 +++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/components/admin/__tests__/coach-feedback-section.test.tsx b/src/components/admin/__tests__/coach-feedback-section.test.tsx index 8886fb700..a68e821d3 100644 --- a/src/components/admin/__tests__/coach-feedback-section.test.tsx +++ b/src/components/admin/__tests__/coach-feedback-section.test.tsx @@ -19,6 +19,7 @@ vi.mock("@tanstack/react-query", () => ({ })); import { I18nProvider } from "@/lib/i18n/context"; +import { AiQualitySection } from "../ai-quality-section"; import { CoachFeedbackSection } from "../coach-feedback-section"; function render() { @@ -138,3 +139,25 @@ describe("CoachFeedbackSection", () => { } }); }); + +describe("the Coach admin page's two quality cards", () => { + it("read one cache cell, so the page asks for the summary once", () => { + useQueryMock.mockReset().mockReturnValue({ + isLoading: true, + isError: false, + data: null, + }); + renderToStaticMarkup( + + + + , + ); + + const keys = useQueryMock.mock.calls.map( + ([options]) => (options as { queryKey: unknown }).queryKey, + ); + expect(keys).toHaveLength(2); + expect(keys[1]).toEqual(keys[0]); + }); +}); diff --git a/src/components/admin/coach-feedback-section.tsx b/src/components/admin/coach-feedback-section.tsx index 3eb6659bb..1fcdab3bc 100644 --- a/src/components/admin/coach-feedback-section.tsx +++ b/src/components/admin/coach-feedback-section.tsx @@ -51,7 +51,9 @@ export function CoachFeedbackSection() { const fmt = useFormatters(); const query = useQuery({ - queryKey: queryKeys.adminCoachFeedback(), + // The same read as the AI quality section beside it, so one request + // serves both cards. + queryKey: queryKeys.adminAiQuality(), queryFn: async () => { const data = await apiGet<{ summary: FeedbackSummary | null } | null>( "/api/admin/ai-quality", From 6a1c91d5d462b1431e11a40640cc9ac3929ade3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 21:50:09 +0100 Subject: [PATCH 05/13] perf(insights): mount the medication efficacy summary when near Insights > Medications sent two requests per medication on entry: the compliance calendar inside each card and the efficacy read behind the summary card, which sits below every card and shows one line per medication. The summary now mounts through `DeferUntilNear`, like the overview's sections, so the efficacy reads go out only when the reader scrolls toward it. The cards and what the summary shows are unchanged. The comment that called the summary "never a second computation" goes: the efficacy route builds the view on every request. --- .../medications/__tests__/page.test.tsx | 81 +++++++++++++++++++ src/app/insights/medications/page.tsx | 13 +-- 2 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 src/app/insights/medications/__tests__/page.test.tsx diff --git a/src/app/insights/medications/__tests__/page.test.tsx b/src/app/insights/medications/__tests__/page.test.tsx new file mode 100644 index 000000000..7503f44fd --- /dev/null +++ b/src/app/insights/medications/__tests__/page.test.tsx @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { ReactNode } from "react"; + +/** + * Insights > Medications: the efficacy summary sits below every medication + * card and asks one efficacy read per medication. On entry it holds its slot + * with a sentinel and mounts only when scrolled near, so opening the page + * sends the cards' reads and not the summary's. + */ + +vi.mock("@tanstack/react-query", () => ({ + useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => + queryKey[1] === "comprehensive" + ? { + data: { + medications: [ + { + id: "m1", + name: "Ramipril", + dose: "5 mg", + category: "BLOOD_PRESSURE", + compliance7: 100, + compliance30: 95, + streak: 7, + taken7: 7, + skipped7: 0, + missed7: 0, + }, + ], + }, + isLoading: false, + isError: false, + } + : { data: undefined, isLoading: false, isError: false }, +})); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true }), +})); +vi.mock("@/hooks/use-ai-capability", () => ({ + useAiCapability: () => ({ available: false }), + useAiProviderState: () => ({ responseTimeoutMs: null }), +})); +vi.mock("@/components/insights/medication-efficacy-summary", () => ({ + MedicationEfficacySummary: () =>
, +})); +vi.mock("@/components/insights/sub-page-shell", () => ({ + SubPageShell: ({ children }: { children: ReactNode }) => <>{children}, +})); +vi.mock("@/components/insights/glp1-substance-curve-summary", () => ({ + Glp1SubstanceCurveSummary: () => null, +})); +vi.mock("@/components/insights/metric-target-summary", () => ({ + MetricTargetSummary: () => null, +})); +vi.mock("@/components/insights/insight-status-card", () => ({ + InsightStatusCard: () => null, +})); +vi.mock("@/components/insights/coach-launch-button", () => ({ + CoachLaunchButton: () => null, +})); +vi.mock("@/components/charts/compliance-heatmap", () => ({ + ComplianceHeatmap: () => null, +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import InsightsMedikamentePage from "../page"; + +describe("/insights/medications on entry", () => { + it("renders the cards and holds the efficacy summary until scrolled near", () => { + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toContain("Ramipril"); + expect(html).toContain('data-deferred-section="medication-efficacy"'); + expect(html).not.toContain("efficacy-summary-mounted"); + }); +}); diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx index bd17dc53f..efe9abb03 100644 --- a/src/app/insights/medications/page.tsx +++ b/src/app/insights/medications/page.tsx @@ -24,6 +24,7 @@ import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; import { MedicationEfficacySummary } from "@/components/insights/medication-efficacy-summary"; +import { DeferUntilNear } from "@/components/insights/defer-until-near"; import { Glp1SubstanceCurveSummary } from "@/components/insights/glp1-substance-curve-summary"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { TileHeader } from "@/components/insights/tile-header"; @@ -321,12 +322,12 @@ export default function InsightsMedikamentePage() { })}
- {/* v1.28 — compact efficacy summary: reuses the same server-computed - efficacy DTO the detail-page "Wirkung" tab reads (never a second - computation), association-only, linking through to the full view. */} - ({ id: m.id, name: m.name }))} - /> + {/* v1.28 — one efficacy read per medication, below every card: mounted when near. */} + + ({ id: m.id, name: m.name }))} + /> + {/* v1.28.20 — estimated active-substance curve for GLP-1 medications (additive; renders nothing when the user has no GLP-1 medication). */} From e1d143d689cef21aedd7f0de3e6e2bf708a806d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 21:55:27 +0100 Subject: [PATCH 06/13] fix(medications): read the dose history's window, not every intake The history tab asks for a window of at most a year, but the route read every intake the medication ever had: a medication taken three times a day for five years read about 5,500 rows on each open. The full read also served to find the last intake for rolling cadences. The route now reads the rows anchored or taken in the window, and of the rows before it only the latest take, which is the anchor a rolling grid re-starts from and the last intake when none falls in the window. Earlier takes place no slot in the window, so the ledger is the same. The read moves into its own function, which shortens the handler. --- .../baselines/source-function-lines.json | 4 +- .../[id]/dose-history/__tests__/route.test.ts | 73 ++++++++++++++++- .../medications/[id]/dose-history/route.ts | 82 ++++++++++++++----- 3 files changed, 135 insertions(+), 24 deletions(-) diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index a6c30e3f9..ba2210e20 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 339, - "total": 110080, + "total": 110065, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -27,7 +27,7 @@ "app/api/mcp/oauth/token/route.ts :: POST": 250, "app/api/mcp/oauth/token/route.ts :: POST > withBackgroundEvent(arg)": 248, "app/api/measurement-reminders/[id]/route.ts :: apiHandler(arg) #2": 239, - "app/api/medications/[id]/dose-history/route.ts :: apiHandler(arg)": 256, + "app/api/medications/[id]/dose-history/route.ts :: apiHandler(arg)": 241, "app/api/medications/[id]/inventory/[itemId]/route.ts :: apiHandler(arg)": 204, "app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts :: apiHandler(arg)": 250, "app/api/mental-health/assessments/route.ts :: postAssessment": 249, diff --git a/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts b/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts index 5c55e140a..8edbfb47d 100644 --- a/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts @@ -19,7 +19,7 @@ import { NextRequest } from "next/server"; vi.mock("@/lib/db", () => ({ prisma: { medication: { findUnique: vi.fn() }, - medicationIntakeEvent: { findMany: vi.fn() }, + medicationIntakeEvent: { findMany: vi.fn(), findFirst: vi.fn() }, }, })); @@ -567,3 +567,74 @@ describe("GET /api/medications/[id]/dose-history — slots before creation (#102 }); } }); + +/** + * The history reads the window's rows, not every intake the medication ever + * had: for a medication taken for years each open of the tab read thousands + * of rows to show at most a year. Of the rows before the window only the + * latest take is read, because a rolling grid re-starts from it. + */ +describe("GET /api/medications/[id]/dose-history — the read is bounded to the window", () => { + const FROM = new Date("2026-06-01T00:00:00Z"); + const TO = new Date("2026-06-15T00:00:00Z"); + const windowQuery = `?from=${encodeURIComponent(FROM.toISOString())}&to=${encodeURIComponent(TO.toISOString())}`; + + it("asks only for rows anchored or taken in the window", async () => { + await GET(getReq(windowQuery), ROUTE_PARAMS); + + const where = vi.mocked(prisma.medicationIntakeEvent.findMany).mock + .calls[0][0]?.where; + expect(where).toMatchObject({ + OR: [{ scheduledFor: { gte: FROM } }, { takenAt: { gte: FROM } }], + }); + const before = vi.mocked(prisma.medicationIntakeEvent.findFirst).mock + .calls[0][0]; + expect(before?.where).toMatchObject({ + skipped: false, + scheduledFor: { lt: FROM }, + takenAt: { lt: FROM }, + }); + }); + + it("re-starts a rolling grid from the latest take before the window", async () => { + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "med-1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00Z"), + schedules: [ + { + id: "sched-1", + windowStart: "08:00", + windowEnd: "08:00", + daysOfWeek: null, + timesOfDay: ["08:00"], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: 7, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }, + ], + } as never); + // Taken four days before the window opens, nothing since: the next dose + // was due inside the window and reads as missed there. + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({ + takenAt: new Date("2026-05-28T06:00:00Z"), + } as never); + + const res = await GET(getReq(windowQuery), ROUTE_PARAMS); + expect(res.status).toBe(200); + const rows = (await res.json()).data.rows as Array<{ + at: string; + kind: string; + status: string; + }>; + + expect(rows.map((r) => [r.at.slice(0, 10), r.kind, r.status])).toEqual([ + ["2026-06-04", "slot", "missed"], + ]); + }); +}); diff --git a/src/app/api/medications/[id]/dose-history/route.ts b/src/app/api/medications/[id]/dose-history/route.ts index 7d9f7cf5b..652ae6d06 100644 --- a/src/app/api/medications/[id]/dose-history/route.ts +++ b/src/app/api/medications/[id]/dose-history/route.ts @@ -101,6 +101,61 @@ interface SerializedDoseHistoryRow { } | null; } +/** + * The intakes a window needs: the rows anchored in it or taken in it (a late + * take on an older slot is an intake instant of the window), and of all the + * rows before it only the latest take, the anchor a rolling grid re-starts + * from and the last intake when none falls in the window. Earlier takes place + * no slot in the window. The whole history used to be read on every open, so + * a medication taken for years read thousands of rows to show at most a year. + */ +async function readWindowIntakes( + medicationId: string, + userId: string, + from: Date, +) { + const [events, takenBefore] = await Promise.all([ + prisma.medicationIntakeEvent.findMany({ + where: { + medicationId, + userId, + deletedAt: null, + OR: [{ scheduledFor: { gte: from } }, { takenAt: { gte: from } }], + }, + orderBy: [{ scheduledFor: "desc" }, { id: "desc" }], + select: { + id: true, + scheduledFor: true, + takenAt: true, + skipped: true, + autoMissed: true, + // v1.15.20 — USER_PIN rows bind by anchor in the read ledger. + attributionSource: true, + // v1.16.4 — per-intake dose override for the ledger's deviation hint. + doseTaken: true, + // v1.32.8 (iOS #64) — write provenance so the client can label how + // each dose was recorded (Web / API / Reminder / Import / AppleHealth). + source: true, + // v1.42.2 — the history labels an automatically recorded dose. + autoRecorded: true, + }, + }), + prisma.medicationIntakeEvent.findFirst({ + where: { + medicationId, + userId, + deletedAt: null, + skipped: false, + scheduledFor: { lt: from }, + takenAt: { lt: from }, + }, + orderBy: [{ takenAt: "desc" }, { id: "desc" }], + select: { takenAt: true }, + }), + ]); + return { events, anchorBefore: takenBefore?.takenAt ?? null }; +} + export const GET = apiHandler( async (request: NextRequest, { params }: RouteParams) => { const { user } = await requireAuth(); @@ -165,26 +220,7 @@ export const GET = apiHandler( Math.max(requestedFrom.getTime(), spanFloor.getTime()), ); - const events = await prisma.medicationIntakeEvent.findMany({ - where: { medicationId: id, userId: user.id, deletedAt: null }, - orderBy: [{ scheduledFor: "desc" }, { id: "desc" }], - select: { - id: true, - scheduledFor: true, - takenAt: true, - skipped: true, - autoMissed: true, - // v1.15.20 — USER_PIN rows bind by anchor in the read ledger. - attributionSource: true, - // v1.16.4 — per-intake dose override for the ledger's deviation hint. - doseTaken: true, - // v1.32.8 (iOS #64) — write provenance so the client can label how - // each dose was recorded (Web / API / Reminder / Import / AppleHealth). - source: true, - // v1.42.2 — the history labels an automatically recorded dose. - autoRecorded: true, - }, - }); + const { events, anchorBefore } = await readWindowIntakes(id, user.id, from); const mapped = events.map((e) => ({ id: e.id, @@ -198,7 +234,10 @@ export const GET = apiHandler( autoRecorded: e.autoRecorded, })); - const lastIntakeAt = lastNonSkippedTakenAt(mapped); + const lastIntakeAt = lastNonSkippedTakenAt([ + ...mapped, + ...(anchorBefore ? [{ takenAt: anchorBefore, skipped: false }] : []), + ]); // A dose may be recorded before its slot's own time: the dose window opens // up to two hours early, and a skip can be logged ahead. Such a row is @@ -263,6 +302,7 @@ export const GET = apiHandler( const intakeInstants = mapped .filter((e) => !e.skipped && e.takenAt !== null && e.takenAt <= to) .map((e) => e.takenAt as Date) + .concat(anchorBefore ? [anchorBefore] : []) .sort((a, b) => a.getTime() - b.getTime()); const groups = buildBandsForSchedulesWithEras({ From d2ac3b91bd6a2f6ff8030d1c929bdb9c150ff6e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 21:59:46 +0100 Subject: [PATCH 07/13] perf(insights): stop the tab strip reading the briefing on every page The layout shell around every Insights page read the advisor briefing (`GET /api/insights/generate`, about half a second on the demo) for the regenerate button on the tab strip, and once the answer arrived it loaded the schema chunk that parses it. The strip shows no briefing; only the overview does, and it reads the same cell itself. On a metric page both were spent on a button. The shell's hook now reads the briefing only while its own regenerate settles. A regenerate from a page that never read the briefing reads it first, so a timed-out attempt still settles against the briefing it replaces instead of taking the old one for fresh. The button, its toasts and the overview are unchanged. The strip's comments that said only the overview carries the button are corrected. --- .../insights-layout-shell-reads.test.tsx | 76 +++++++++++++++++++ .../use-insights-advisor-baseline.test.ts | 68 +++++++++++++++++ .../insights/insights-layout-shell.tsx | 18 ++--- .../insights/insights-tab-strip.tsx | 13 ++-- .../insights/use-insights-advisor.ts | 41 +++++++++- 5 files changed, 195 insertions(+), 21 deletions(-) create mode 100644 src/components/insights/__tests__/insights-layout-shell-reads.test.tsx create mode 100644 src/components/insights/__tests__/use-insights-advisor-baseline.test.ts diff --git a/src/components/insights/__tests__/insights-layout-shell-reads.test.tsx b/src/components/insights/__tests__/insights-layout-shell-reads.test.tsx new file mode 100644 index 000000000..1d6bfb37c --- /dev/null +++ b/src/components/insights/__tests__/insights-layout-shell-reads.test.tsx @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +/** + * The layout shell wraps every Insights page. It owns the regenerate button + * on the tab strip but shows no briefing, so it must not read the briefing + * on entry: on a metric page that read was a request (and the schema chunk + * that parses its answer) for a button. The button itself stays. + */ + +const queries: Array<{ queryKey: readonly unknown[]; enabled?: boolean }> = []; +const stripProps: Array<{ onRegenerate?: unknown }> = []; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: (options: { queryKey: readonly unknown[]; enabled?: boolean }) => { + queries.push(options); + return { data: undefined, isLoading: false, isError: false }; + }, + useMutation: () => ({ mutate: vi.fn(), isPending: false, data: undefined }), + useQueryClient: () => ({ + getQueryCache: () => ({ find: () => undefined }), + }), +})); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true, user: { modules: {} } }), +})); +vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); +vi.mock("@/hooks/use-ai-capability", () => ({ + useAiCapability: () => ({ available: true }), + useAiProviderState: () => ({ responseTimeoutMs: null }), +})); +vi.mock("@/hooks/use-surface-visible", () => ({ + useSurfaceVisible: () => true, +})); +vi.mock("@/hooks/use-workouts", () => ({ + useWorkouts: () => ({ data: undefined }), +})); +vi.mock("@/lib/queries/use-analytics-query", () => ({ + useAnalyticsQuery: () => ({ data: undefined }), +})); +vi.mock("@/hooks/use-insights-layout", () => ({ + useInsightsLayoutQuery: () => ({ + layout: { tiles: [] }, + isSuccess: false, + }), +})); +vi.mock("@/components/insights/use-ecg-list", () => ({ + useEcgList: () => ({ data: undefined }), +})); +vi.mock("@/components/insights/insights-tab-strip", () => ({ + InsightsTabStrip: (props: { onRegenerate?: unknown }) => { + stripProps.push(props); + return null; + }, +})); + +import { queryKeys } from "@/lib/query-keys"; +import { InsightsLayoutShell } from "../insights-layout-shell"; + +describe("InsightsLayoutShell — what it reads on entry", () => { + it("keeps the regenerate button and sends no briefing read", () => { + renderToStaticMarkup( + +
+ , + ); + + const advisorKey = JSON.stringify(queryKeys.insightsAdvisor()); + const advisor = queries.filter( + (q) => JSON.stringify(q.queryKey) === advisorKey, + ); + expect(advisor).toHaveLength(1); + expect(advisor[0].enabled).toBe(false); + expect(typeof stripProps[0]?.onRegenerate).toBe("function"); + }); +}); diff --git a/src/components/insights/__tests__/use-insights-advisor-baseline.test.ts b/src/components/insights/__tests__/use-insights-advisor-baseline.test.ts new file mode 100644 index 000000000..e1cb0c54f --- /dev/null +++ b/src/components/insights/__tests__/use-insights-advisor-baseline.test.ts @@ -0,0 +1,68 @@ +/** + * The tab strip owns the regenerate button on every Insights page but shows + * no briefing, so it no longer reads one on entry. A regenerate from a page + * that never read the briefing still needs the briefing it replaces as the + * baseline a timed-out attempt settles against; without it the old briefing + * would read as the fresh one. `ensureAdvisorBaseline` reads it first, and + * only when the cache holds none. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; + +const apiFetchRaw = vi.fn(); + +vi.mock("@/lib/api/api-fetch", () => ({ + apiFetchRaw: (...a: unknown[]) => apiFetchRaw(...a), +})); + +import { queryKeys } from "@/lib/query-keys"; +import { ensureAdvisorBaseline } from "../use-insights-advisor"; + +const CACHED_AT = "2026-11-10T05:00:00.000Z"; + +beforeEach(() => { + apiFetchRaw.mockReset().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + data: { insights: {}, cached: true, cachedAt: CACHED_AT }, + }), + } as unknown as Response); +}); + +describe("ensureAdvisorBaseline", () => { + it("reads the briefing a regenerate replaces when nothing has read it yet", async () => { + const queryClient = new QueryClient(); + + await ensureAdvisorBaseline(queryClient); + + expect(apiFetchRaw).toHaveBeenCalledTimes(1); + expect(apiFetchRaw.mock.calls[0][1]).toMatchObject({ method: "GET" }); + const cached = queryClient.getQueryData<{ + payload: { cachedAt: string } | null; + }>(queryKeys.insightsAdvisor()); + expect(cached?.payload?.cachedAt).toBe(CACHED_AT); + }); + + it("asks nothing when the overview already read it", async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(queryKeys.insightsAdvisor(), { + payload: { cachedAt: CACHED_AT }, + outcome: "fresh", + }); + + await ensureAdvisorBaseline(queryClient); + + expect(apiFetchRaw).not.toHaveBeenCalled(); + }); + + it("leaves the baseline empty when the read fails", async () => { + apiFetchRaw.mockRejectedValue(new Error("offline")); + const queryClient = new QueryClient(); + + await expect(ensureAdvisorBaseline(queryClient)).resolves.toBeUndefined(); + expect( + queryClient.getQueryData(queryKeys.insightsAdvisor()), + ).toBeUndefined(); + }); +}); diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index 0fb78dffd..7bba6a385 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -24,12 +24,12 @@ import { useEcgList } from "@/components/insights/use-ecg-list"; /** * v1.4.25 — client shell for `src/app/insights/layout.tsx`. * - * Owns the advisor query so the regenerate button on the sticky tab - * strip works from every Insights surface (mother + the seven sub- - * pages). TanStack Query dedup keeps the cost negligible: the mother - * page's own `useInsightsAdvisorQuery` consumer shares the same - * `queryKeys.insightsAdvisor()` key, so the two consumers reuse the - * same cache entry without extra network traffic. + * Owns the regenerate button on the sticky tab strip, so it works from + * every Insights surface. The strip shows no briefing, so the shell does + * not read it: the overview's own `useInsightsAdvisorQuery` does, and the + * shell's hook reads it only while its regenerate settles. Every metric + * page used to send the briefing read (and load the schema chunk that + * parses it) for a button. * * v1.4.27 — also owns the analytics + comprehensive reads that * power the tab-strip availability gate. The analytics consumer goes @@ -55,8 +55,8 @@ interface ComprehensivePayload { export function InsightsLayoutShell({ children }: { children: ReactNode }) { const { isAuthenticated, user } = useAuth(); - // The briefing read and the regenerate button follow the `briefing` - // capability on `/api/auth/me`: the server resolves the operator's switch, + // The regenerate button follows the `briefing` capability on + // `/api/auth/me`: the server resolves the operator's switch, // the person's AI analysis opt-out, provider presence and consent into one // answer, and the strip offers regenerate only while that answer is yes. // Unavailable while `/me` loads, so nothing fires before the answer lands. @@ -68,7 +68,7 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { const mounted = useMounted(); const advisorEnabled = mounted && isAuthenticated && briefing.available; - const advisor = useInsightsAdvisorQuery(advisorEnabled); + const advisor = useInsightsAdvisorQuery(false); // Shared analytics fetch — the layout shell only reads // `summaries[METRIC].count` for the tab-strip availability gate, so diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index 53981829d..23b012e52 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -61,20 +61,17 @@ import { isSurfaceVisible } from "@/lib/modules/surface"; * - Active pill gets `aria-current="page"`. * - The regenerate button has an `aria-label` so the icon-only * affordance is announced. The button only renders when - * `onRegenerate` is wired — sub-pages don't carry the regenerate - * handler today (the mother page owns the advisor query and - * forwards the trigger via the layout slot). + * `onRegenerate` is wired; the layout shell wires it on every + * Insights page while the briefing capability is available. * - `prefers-reduced-motion` honoured at the global level (no * scroll-into-view here — Next.js routing handles focus + scroll). */ export interface InsightsTabStripProps { /** - * Optional regenerate handler. Only the mother page passes this; - * sub-pages render the strip without the right-slot button. - * Wiring the strip in `layout.tsx` means we'd need a layout-level - * advisor query — for v1.4.25 we keep the strip stateless and the - * regenerate affordance on the mother page only. + * Optional regenerate handler. The layout shell passes it on every + * Insights page while the briefing capability is available; without + * it the strip renders no customise or regenerate control. */ onRegenerate?: () => void; /** Spinner state — disables the button and swaps the icon. */ diff --git a/src/components/insights/use-insights-advisor.ts b/src/components/insights/use-insights-advisor.ts index 929ed2717..27c61e142 100644 --- a/src/components/insights/use-insights-advisor.ts +++ b/src/components/insights/use-insights-advisor.ts @@ -1,7 +1,12 @@ "use client"; import { useCallback, useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + useMutation, + useQuery, + useQueryClient, + type QueryClient, +} from "@tanstack/react-query"; import { pollAttemptCount } from "@/lib/queries/refetch-interval"; import type { InsightResult } from "@/lib/ai/types"; // Type-only — the runtime schemas load lazily inside `fetchAdvisor` so @@ -429,6 +434,27 @@ export async function fetchAdvisor( return { payload, outcome: "fresh" }; } +/** + * The payload a regenerate settles against. A surface that shows no briefing + * (the tab strip on a metric page) has not read it, and a timed-out + * regenerate without a baseline takes whatever briefing the cache holds for + * the fresh one. So the read runs first when the cache is empty; a failed + * read leaves the baseline empty, as it was before. + */ +export async function ensureAdvisorBaseline( + queryClient: QueryClient, +): Promise { + if (queryClient.getQueryData(queryKeys.insightsAdvisor()) !== undefined) { + return; + } + await queryClient + .fetchQuery({ + queryKey: queryKeys.insightsAdvisor(), + queryFn: () => fetchAdvisor(), + }) + .catch(() => undefined); +} + export interface UseInsightsAdvisorResult { payload: InsightAdvisorPayload | null; isLoading: boolean; @@ -485,7 +511,9 @@ export interface UseInsightsAdvisorResult { * available, whatever the caller passes: every consumer (the overview, the * tab strip) gets the same gate, and none can re-enable the read behind the * other's back. While `/me` loads the capability reads unavailable, so the - * first paint never fires a request only to have it refused. + * first paint never fires a request only to have it refused. The tab strip + * passes `false`: it shows no briefing and only owns the regenerate button, + * so it reads the briefing only while its own regenerate settles. */ export function useInsightsAdvisorQuery( enabled: boolean, @@ -506,7 +534,9 @@ export function useInsightsAdvisorQuery( // outcome) so the read path can surface WHY a payload is missing // (`no-provider` → connect-AI hint instead of a dead regenerate CTA). queryFn: () => fetchAdvisor(), - enabled: enabled && briefing.available, + // A caller that shows no briefing still reads it while its own + // regenerate settles: the settle is decided on these polls. + enabled: (enabled || settling !== null) && briefing.available, // 24h cache window matches the server-side `insightsCachedAt` TTL. staleTime: 60 * 60 * 1000, retry: false, @@ -556,7 +586,10 @@ export function useInsightsAdvisorQuery( } const mutation = useMutation({ - mutationFn: () => fetchAdvisor({ force: true, responseTimeoutMs }), + mutationFn: async () => { + await ensureAdvisorBaseline(queryClient); + return fetchAdvisor({ force: true, responseTimeoutMs }); + }, onSuccess: (result) => { if (result.payload) { // A genuinely fresh generation landed — write it into the shared From ea6e94adcf42268ca9311295660b40ad684bce2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 22:02:49 +0100 Subject: [PATCH 08/13] perf(monitoring): read the monitoring switches in the root layout Every document load, the sign-in page included, mounted a client effect that asked `/api/monitoring/settings` for three instance-wide values (false, null, false on a default install) before it mounted the analytics script or the error reporter. The root layout is a server component and now reads them while it renders and passes them in, so the request and the effect go. The key-mismatch page reads nothing, as before. The route stays for other callers. --- src/app/layout.tsx | 18 ++++++- .../monitoring/__tests__/bootstrap.test.tsx | 49 +++++++++++++++++ src/components/monitoring/bootstrap.tsx | 52 ++++++------------- 3 files changed, 80 insertions(+), 39 deletions(-) create mode 100644 src/components/monitoring/__tests__/bootstrap.test.tsx diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2e41e3b20..ad5c734a4 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -8,6 +8,7 @@ import "./globals.css"; import { Providers } from "@/components/providers"; import { AuthShell } from "@/components/layout/auth-shell"; import { MonitoringBootstrap } from "@/components/monitoring/bootstrap"; +import { getPublicMonitoringSettings } from "@/lib/monitoring-settings"; import { WebVitalsReporter } from "@/components/monitoring/web-vitals-reporter"; import { resolveInitialLocale } from "@/lib/i18n/resolve-initial-locale"; import { isKeyMismatch } from "@/lib/boot/key-mismatch-state"; @@ -86,7 +87,12 @@ export default async function RootLayout({ ? ((await headers()).get("x-nonce") ?? undefined) : undefined; - const initialLocale = await resolveInitialLocale(); + // The monitoring switches ride the document render instead of a request + // from every page load; the key-mismatch page reads no data. + const [initialLocale, monitoring] = await Promise.all([ + resolveInitialLocale(), + isKeyMismatch() ? null : getPublicMonitoringSettings(), + ]); return ( @@ -139,7 +145,15 @@ export default async function RootLayout({ ) : ( - + {/* `DEMO_MODE` is a server-only env var; the proxy uses it to block mutations. Resolve it here (server component) and diff --git a/src/components/monitoring/__tests__/bootstrap.test.tsx b/src/components/monitoring/__tests__/bootstrap.test.tsx new file mode 100644 index 000000000..e0a8c8fdb --- /dev/null +++ b/src/components/monitoring/__tests__/bootstrap.test.tsx @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +/** + * The monitoring switches come from the root layout's render, not from a + * request every page load sent to `/api/monitoring/settings` for three + * instance-wide values. The first render already mounts what they turn on. + */ + +const apiGet = vi.fn(); +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: (...args: unknown[]) => apiGet(...args), +})); +vi.mock("@/components/monitoring/umami-script", () => ({ + UmamiScript: (props: { enabled: boolean; websiteId: string | null }) => ( + + ), +})); +vi.mock("@/components/monitoring/glitchtip-reporter", () => ({ + GlitchtipReporter: (props: { enabled: boolean }) => ( + + ), +})); + +import { MonitoringBootstrap } from "../bootstrap"; + +describe("MonitoringBootstrap", () => { + it("mounts what the layout's switches turn on, without asking the server", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('data-umami="true:site-1"'); + expect(html).toContain('data-glitchtip="true"'); + expect(apiGet).not.toHaveBeenCalled(); + }); + + it("mounts nothing without settings", () => { + expect(renderToStaticMarkup()).toBe( + "", + ); + }); +}); diff --git a/src/components/monitoring/bootstrap.tsx b/src/components/monitoring/bootstrap.tsx index 0598dcff5..1f6c790ae 100644 --- a/src/components/monitoring/bootstrap.tsx +++ b/src/components/monitoring/bootstrap.tsx @@ -1,43 +1,21 @@ -"use client"; - -import { useEffect, useState } from "react"; import { UmamiScript } from "@/components/monitoring/umami-script"; import { GlitchtipReporter } from "@/components/monitoring/glitchtip-reporter"; -import { apiGet } from "@/lib/api/api-fetch"; - -interface MonitoringSettings { - umamiEnabled: boolean; - umamiWebsiteId: string | null; - glitchtipEnabled: boolean; -} - -export function MonitoringBootstrap() { - const [settings, setSettings] = useState(null); - - useEffect(() => { - let active = true; - - apiGet("/api/monitoring/settings", { - cache: "no-store", - }) - .then((data) => { - if (!active || !data) return; - setSettings(data); - }) - .catch(() => { - if (!active) return; - setSettings({ - umamiEnabled: false, - umamiWebsiteId: null, - glitchtipEnabled: false, - }); - }); - - return () => { - active = false; - }; - }, []); +import type { PublicMonitoringSettings } from "@/lib/monitoring-settings"; +/** + * Mounts the operator's analytics script and error reporter. The root layout + * reads the instance-wide switches on the server and passes them in, so no + * page load asks `/api/monitoring/settings` for three values the document + * render already had. `null` (the key-mismatch page) mounts nothing. + */ +export function MonitoringBootstrap({ + settings, +}: { + settings: Pick< + PublicMonitoringSettings, + "umamiEnabled" | "umamiWebsiteId" | "glitchtipEnabled" + > | null; +}) { if (!settings) return null; return ( From b970ea746811efb441708972187d7a6dadbfe6ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 22:02:50 +0100 Subject: [PATCH 09/13] perf(dashboard): seed the mood chart's series from the snapshot The dashboard snapshot carries the mood tile's series from the same engine `/api/mood/analytics` answers from, and the snapshot already seeds the widget layout, but nothing seeded the mood analytics cell the mood chart reads, so every dashboard open with the mood chart asked for the series again. Both seed points, the server render and the client snapshot read, now fill that cell too, under the same rule as the layout: only an empty cell, and only when the tile has readings, the one case where its summary is the route's. --- src/app/page.tsx | 13 ++++++ .../__tests__/use-dashboard-snapshot.test.tsx | 40 +++++++++++++++++++ src/lib/queries/use-dashboard-snapshot.ts | 15 +++++++ 3 files changed, 68 insertions(+) diff --git a/src/app/page.tsx b/src/app/page.tsx index 48af2ec52..bda704c49 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -102,6 +102,19 @@ export default async function DashboardPage() { const queryClient = new QueryClient(); queryClient.setQueryData(queryKeys.dashboardSnapshot(locale), wireBody); queryClient.setQueryData(queryKeys.dashboardWidgets(), wireBody.layout); + // The mood chart's series rides the mood tile (same engine as + // `/api/mood/analytics`); seeded with readings only, where the tile's + // summary is the route's. + const mood = ( + wireBody.tiles as + { mood?: { entries: unknown[]; summary: unknown } } | undefined + )?.mood; + if (mood?.summary && mood.entries.length > 0) { + queryClient.setQueryData(queryKeys.moodAnalytics(), { + entries: mood.entries, + summary: mood.summary, + }); + } // Above-the-fold prefetch runs ONLY in snapshot mode: legacy mode derives // its chart-type list from analytics queries the RSC did not run, so there diff --git a/src/lib/queries/__tests__/use-dashboard-snapshot.test.tsx b/src/lib/queries/__tests__/use-dashboard-snapshot.test.tsx index c19d91a69..892a178f3 100644 --- a/src/lib/queries/__tests__/use-dashboard-snapshot.test.tsx +++ b/src/lib/queries/__tests__/use-dashboard-snapshot.test.tsx @@ -143,6 +143,46 @@ describe("useDashboardSnapshot — auto-refresh on an open page", () => { await (opts.queryFn as () => Promise)(); expect(setQueryDataMock).not.toHaveBeenCalled(); }); + + it("seeds the mood chart's series from the mood tile, so the chart asks for nothing", async () => { + const entries = [{ date: "2026-11-10", score: 4, samples: 1 }]; + const summary = { count: 1, latest: 4 }; + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + envelopeResponse({ + layout: { widgets: [] }, + tiles: { mood: { entries, summary } }, + }), + ), + ); + getQueryDataMock.mockReturnValue(undefined); + useDashboardSnapshot(); + await (lastOpts().queryFn as () => Promise)(); + expect(setQueryDataMock).toHaveBeenCalledWith(queryKeys.moodAnalytics(), { + entries, + summary, + }); + }); + + it("leaves the mood chart to its own read when the tile has no readings", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + envelopeResponse({ + layout: { widgets: [] }, + tiles: { mood: { entries: [], summary: null } }, + }), + ), + ); + getQueryDataMock.mockReturnValue(undefined); + useDashboardSnapshot(); + await (lastOpts().queryFn as () => Promise)(); + expect(setQueryDataMock).not.toHaveBeenCalledWith( + queryKeys.moodAnalytics(), + expect.anything(), + ); + }); }); describe("prefetchDashboardSnapshot — hydration-safe promise handoff", () => { diff --git a/src/lib/queries/use-dashboard-snapshot.ts b/src/lib/queries/use-dashboard-snapshot.ts index 280564b75..f5b85aff3 100644 --- a/src/lib/queries/use-dashboard-snapshot.ts +++ b/src/lib/queries/use-dashboard-snapshot.ts @@ -194,6 +194,21 @@ function makeSnapshotQueryFn(queryClient: QueryClient) { if (!queryClient.getQueryData(queryKeys.dashboardWidgets())) { queryClient.setQueryData(queryKeys.dashboardWidgets(), snap.layout); } + // The mood tile carries the series `/api/mood/analytics` answers (one + // engine, `buildMoodDailySeries`), which is what the mood chart reads. + // Same seed-when-empty rule, and only with readings, the one case where + // the tile's summary is the route's. + const mood = snap.tiles?.mood; + if ( + mood?.summary && + mood.entries.length > 0 && + !queryClient.getQueryData(queryKeys.moodAnalytics()) + ) { + queryClient.setQueryData(queryKeys.moodAnalytics(), { + entries: mood.entries, + summary: mood.summary, + }); + } return snap; }; } From eec52534d5364c1377553b09e7c99a0fe6503a5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 22:09:23 +0100 Subject: [PATCH 10/13] fix(insights): ask the empty-state Coach question in the reader's language A metric page without data offers to ask the Coach about the metric. 44 of the 49 pages passed that question as an English literal, so a German, French or Korean reader got an English first message; only 5 read it from the catalog. 31 of the 44 were one sentence with the metric's name swapped in. `HealthKitMetricPage` now asks that sentence once, translated, around the page's own title, and the 31 pages drop the prop. The 13 pages with a question of their own (steps, HRV, sleep, mood, workouts, blood pressure and others) read it from the catalog like the 5 did, in all ten languages. --- messages/de.json | 42 +++++++---- messages/en.json | 42 +++++++---- messages/es.json | 42 +++++++---- messages/fr.json | 42 +++++++---- messages/it.json | 42 +++++++---- messages/ko.json | 42 +++++++---- messages/nl.json | 42 +++++++---- messages/pl.json | 42 +++++++---- messages/pt-BR.json | 42 +++++++---- messages/ru.json | 42 +++++++---- src/app/insights/active-energy/page.tsx | 4 +- src/app/insights/audio-events/page.tsx | 1 - src/app/insights/blood-glucose/page.tsx | 1 - src/app/insights/blood-pressure/page.tsx | 2 +- src/app/insights/bmi/page.tsx | 2 +- src/app/insights/body-temperature/page.tsx | 4 +- src/app/insights/body-water/page.tsx | 1 - src/app/insights/bone-mass/page.tsx | 1 - .../insights/breathing-disturbances/page.tsx | 1 - src/app/insights/cardio-fitness/page.tsx | 1 - src/app/insights/cardio-recovery/page.tsx | 1 - src/app/insights/daylight/page.tsx | 1 - src/app/insights/double-support-time/page.tsx | 1 - src/app/insights/environmental-audio/page.tsx | 1 - src/app/insights/falls/page.tsx | 4 +- src/app/insights/fat-free-mass/page.tsx | 1 - src/app/insights/fat-mass/page.tsx | 1 - src/app/insights/flights-climbed/page.tsx | 1 - src/app/insights/headphone-audio/page.tsx | 1 - src/app/insights/hrv/page.tsx | 4 +- src/app/insights/lean-body-mass/page.tsx | 1 - src/app/insights/mood/page-client.tsx | 2 +- src/app/insights/muscle-mass/page.tsx | 1 - .../insights/oxygen/__tests__/page.test.tsx | 5 +- src/app/insights/oxygen/page.tsx | 4 +- src/app/insights/pulse-wave-velocity/page.tsx | 1 - src/app/insights/pulse/page.tsx | 2 +- src/app/insights/respiratory-rate/page.tsx | 1 - src/app/insights/six-minute-walk/page.tsx | 1 - src/app/insights/skin-temperature/page.tsx | 1 - src/app/insights/sleep/page.tsx | 2 +- src/app/insights/stair-ascent-speed/page.tsx | 1 - src/app/insights/stair-descent-speed/page.tsx | 1 - src/app/insights/step-length/page.tsx | 1 - src/app/insights/steps/page.tsx | 2 +- src/app/insights/vascular-age/page.tsx | 1 - src/app/insights/visceral-fat/page.tsx | 1 - src/app/insights/walking-asymmetry/page.tsx | 1 - src/app/insights/walking-distance/page.tsx | 1 - src/app/insights/walking-heart-rate/page.tsx | 1 - src/app/insights/walking-speed/page.tsx | 1 - src/app/insights/walking-steadiness/page.tsx | 1 - src/app/insights/weight/page.tsx | 2 +- src/app/insights/workouts/page-client.tsx | 2 +- src/app/insights/wrist-temperature/page.tsx | 1 - ...althkit-metric-page-coach-prefill.test.tsx | 74 +++++++++++++++++++ .../insights/healthkit-metric-page.tsx | 18 ++++- 57 files changed, 395 insertions(+), 189 deletions(-) create mode 100644 src/components/insights/__tests__/healthkit-metric-page-coach-prefill.test.tsx diff --git a/messages/de.json b/messages/de.json index c3db429d5..5e9653306 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3516,7 +3516,8 @@ "emptyState": { "title": "Noch keine HRV-Werte", "description": "HRV-Werte erscheinen hier, sobald die Apple Watch (oder ein kompatibles Withings-Gerät) nächtliche Messungen synchronisiert." - } + }, + "coachPrefill": "Ich habe noch keine HRV-Daten erfasst. Was sagt mir die Herzfrequenzvariabilität, und wie erfasse ich sie?" }, "restingHr": { "title": "Ruhepuls", @@ -3535,7 +3536,8 @@ "emptyState": { "title": "Noch keine SpO₂-Werte", "description": "SpO₂-Werte erscheinen, sobald ein kompatibles Wearable (Apple Watch, Withings ScanWatch) eine Messung erfasst." - } + }, + "coachPrefill": "Ich habe noch keine Sauerstoffsättigung erfasst. Was sagt mir die SpO₂, und welchen Bereich sollte ich anstreben?" }, "bodyTemperature": { "title": "Körpertemperatur", @@ -3545,7 +3547,8 @@ "title": "Noch keine Körpertemperatur", "description": "Erfasse eine Körpertemperatur oder lass dein Wearable einen Wert synchronisieren. Der Verlauf erscheint dann hier.", "cta": "Körpertemperatur erfassen" - } + }, + "coachPrefill": "Ich habe noch keine Körpertemperatur erfasst. Was ist der gesunde Bereich, und was sollte ich tun, wenn sie abweicht?" }, "steps": { "title": "Schritte", @@ -3554,7 +3557,8 @@ "emptyState": { "title": "Noch keine Schrittdaten", "description": "Tägliche Schritt-Summen erscheinen hier, sobald Apple Health oder ein Aktivitätsgerät einen Tagesumfang synchronisiert." - } + }, + "coachPrefill": "Ich habe noch keine Schritte erfasst. Was ist ein sinnvolles tägliches Schrittziel, und wie hilft mehr Gehen meiner Gesundheit?" }, "activeEnergy": { "title": "Aktive Energie", @@ -3563,7 +3567,8 @@ "emptyState": { "title": "Noch keine Aktive-Energie-Werte", "description": "Aktive-Energie-Summen erscheinen hier, sobald Apple Health oder ein Withings-Aktivitätsgerät einen Tagesumfang synchronisiert." - } + }, + "coachPrefill": "Ich habe noch keine aktive Energie erfasst. Was ist ein sinnvolles Tagesziel, und wie verhält es sich zum gesamten Kalorienverbrauch?" }, "subPage": { "loadError": "Diese Messgröße konnte gerade nicht geladen werden.", @@ -3658,7 +3663,8 @@ "valuesBack": "Zurück zu Insights", "valuesBackToMetric": "Zurück zu {metric}", "scoresBack": "Zurück zu Insights", - "ecgDescription": "Einkanal-EKG-Aufzeichnungen deines Geräts, jeweils mit dem Ergebnis des Geräts." + "ecgDescription": "Einkanal-EKG-Aufzeichnungen deines Geräts, jeweils mit dem Ergebnis des Geräts.", + "emptyCoachPrefill": "{metric}: Ich habe hier noch nichts erfasst. Was sagt dieser Wert über meine Gesundheit aus, und wie verbessere ich ihn?" }, "units": { "steps": "Schritte", @@ -3758,27 +3764,32 @@ "bloodPressure": { "title": "Noch keine Blutdruckwerte", "description": "Sobald du Blutdruck loggst, erscheinen hier Trend, Zielbereichs-Anteil und WHO-Klassifizierung.", - "cta": "Blutdruck loggen" + "cta": "Blutdruck loggen", + "coachPrefill": "Ich habe noch keinen Blutdruck erfasst. Warum ist er wichtig, und was sollte ich wissen, bevor ich anfange?" }, "weight": { "title": "Noch keine Gewichtswerte", "description": "Logge ein Gewicht, und Verlauf, gleitender Durchschnitt und Zielbereich erscheinen automatisch.", - "cta": "Gewicht loggen" + "cta": "Gewicht loggen", + "coachPrefill": "Ich habe noch kein Gewicht erfasst. Warum ist es wichtig, und was sollte ich wissen, bevor ich mit dem Erfassen anfange?" }, "pulse": { "title": "Noch keine Pulswerte", "description": "Logge einen Puls, dann erscheinen Ruhepuls, HRV und VO₂ max in dieser Ansicht.", - "cta": "Puls loggen" + "cta": "Puls loggen", + "coachPrefill": "Ich habe noch keinen Ruhepuls erfasst. Warum ist er wichtig, und was sollte ich wissen, bevor ich anfange?" }, "bmi": { "title": "BMI noch nicht berechnet", "description": "Sobald Größe und mindestens ein Gewichtswert vorliegen, erscheint hier der BMI-Verlauf samt WHO-Zonen.", - "cta": "Gewicht loggen" + "cta": "Gewicht loggen", + "coachPrefill": "Ich habe noch kein Gewicht erfasst. Warum ist der BMI für mich wichtig, und was sollte ich wissen, bevor ich ihn verfolge?" }, "mood": { "title": "Noch keine Stimmungseinträge", "description": "Logge eine Stimmung, dann erscheinen Verlauf, Stabilitätsband und Korrelationen.", - "cta": "Stimmung loggen" + "cta": "Stimmung loggen", + "coachPrefill": "Ich habe noch keine Stimmung erfasst. Warum lohnt es sich, die Stimmung zu erfassen, und wie fange ich am besten an?" }, "medication": { "title": "Noch keine Medikamente angelegt", @@ -3788,7 +3799,8 @@ "sleep": { "title": "Noch keine Schlafwerte", "description": "Sobald Schlafdaten vorliegen (manuell oder via Withings / Apple Health), erscheint hier der nächtliche Stadien-Verlauf.", - "cta": "Quelle verbinden" + "cta": "Quelle verbinden", + "coachPrefill": "Ich habe noch keine Schlafdaten. Warum lohnt es sich, Schlaf zu erfassen, und was sollte ich wissen, bevor ich eine Quelle verbinde?" } }, "navWorkouts": "Workouts", @@ -3798,7 +3810,8 @@ "description": "Letzte Läufe, Touren, Spaziergänge und Krafteinheiten, quellenübergreifend entdoppelt zwischen Apple Watch und Withings.", "emptyState": { "title": "Noch keine Workouts", - "description": "Workouts kommen per Apple Health vom iPhone und von Withings. Du kannst eins auch von Hand erfassen." + "description": "Workouts kommen per Apple Health vom iPhone und von Withings. Du kannst eins auch von Hand erfassen.", + "coachPrefill": "Ich habe noch keine Workouts erfasst. Warum lohnt es sich, sie zu erfassen, und worauf sollte ich mich zuerst konzentrieren?" }, "detail": { "backToList": "Zurück zu Workouts", @@ -4678,7 +4691,8 @@ "emptyState": { "title": "Noch keine Sturzdaten", "description": "Erkannte Stürze erscheinen hier, sobald Apple Health synchronisiert. Keine Stürze ist das Ziel." - } + }, + "coachPrefill": "Ich habe noch keine Sturzdaten erfasst. Was sagt dieser Wert über meine Gesundheit aus, und wie senke ich mein Sturzrisiko?" }, "navFalls": "Stürze", "sixMinuteWalk": { diff --git a/messages/en.json b/messages/en.json index d897c04e6..786d1e342 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3516,7 +3516,8 @@ "emptyState": { "title": "No HRV samples yet", "description": "HRV samples land here once Apple Watch (or a compatible Withings device) starts syncing nightly readings." - } + }, + "coachPrefill": "I haven't logged any HRV data yet. What does heart-rate variability tell me, and how do I capture it?" }, "restingHr": { "title": "Resting heart rate", @@ -3535,7 +3536,8 @@ "emptyState": { "title": "No SpO₂ samples yet", "description": "SpO₂ samples land here once a compatible wearable (Apple Watch, Withings ScanWatch) captures a reading." - } + }, + "coachPrefill": "I haven't logged any oxygen-saturation data yet. What does SpO₂ tell me, and what range should I aim for?" }, "bodyTemperature": { "title": "Body temperature", @@ -3545,7 +3547,8 @@ "title": "No body temperature yet", "description": "Log a body-temperature reading or let your wearable sync one, and the trend will appear here.", "cta": "Log body temperature" - } + }, + "coachPrefill": "I haven't logged any body temperature yet. What's the healthy range, and what should I do if it drifts?" }, "steps": { "title": "Steps", @@ -3554,7 +3557,8 @@ "emptyState": { "title": "No step data yet", "description": "Daily step totals appear here once Apple Health or an activity device syncs a day's worth of movement." - } + }, + "coachPrefill": "I haven't logged any steps yet. What's a reasonable daily step target, and how does walking more help my health?" }, "activeEnergy": { "title": "Active energy", @@ -3563,7 +3567,8 @@ "emptyState": { "title": "No active-energy data yet", "description": "Active-energy totals land here once Apple Health or a Withings activity device syncs a day's worth of movement." - } + }, + "coachPrefill": "I haven't logged any active energy yet. What's a reasonable daily target, and how does it compare to total calories?" }, "subPage": { "loadError": "Could not load this metric right now.", @@ -3658,7 +3663,8 @@ "valuesBack": "Back to insights", "valuesBackToMetric": "Back to {metric}", "scoresBack": "Back to insights", - "ecgDescription": "Single-lead ECG recordings from your device, each with the device's own result." + "ecgDescription": "Single-lead ECG recordings from your device, each with the device's own result.", + "emptyCoachPrefill": "{metric}: I haven't logged anything here yet. What does this metric tell me about my health, and how do I improve it?" }, "units": { "steps": "steps", @@ -3758,27 +3764,32 @@ "bloodPressure": { "title": "No blood pressure entries yet", "description": "Log a blood pressure reading and the trend chart, in-range share, and WHO classification will appear here.", - "cta": "Log blood pressure" + "cta": "Log blood pressure", + "coachPrefill": "I haven't recorded any blood pressure yet. Why does it matter, and what should I know before I start?" }, "weight": { "title": "No weight entries yet", "description": "Log a weight and the trend, moving average, and target band will appear automatically.", - "cta": "Log weight" + "cta": "Log weight", + "coachPrefill": "I haven't recorded any weight yet. Why does it matter, and what should I know before I start tracking?" }, "pulse": { "title": "No pulse entries yet", "description": "Log a pulse reading and resting pulse, HRV and VO₂ max will appear in this view.", - "cta": "Log pulse" + "cta": "Log pulse", + "coachPrefill": "I haven't recorded any resting pulse yet. Why does it matter, and what should I know before I start?" }, "bmi": { "title": "BMI not yet computed", "description": "Once height and at least one weight entry are present, the BMI trend with WHO zones will appear here.", - "cta": "Log weight" + "cta": "Log weight", + "coachPrefill": "I haven't recorded any weight yet. Why does BMI matter for me, and what should I know before I start tracking it?" }, "mood": { "title": "No mood entries yet", "description": "Log a mood entry and the trend, stability band and correlations will appear.", - "cta": "Log mood" + "cta": "Log mood", + "coachPrefill": "I haven't logged any mood entries yet. Why does mood tracking matter, and how should I start?" }, "medication": { "title": "No medications set up yet", @@ -3788,7 +3799,8 @@ "sleep": { "title": "No sleep entries yet", "description": "Once sleep data lands (manual or via Withings / Apple Health), the per-night stage breakdown will appear here.", - "cta": "Connect a source" + "cta": "Connect a source", + "coachPrefill": "I don't have any sleep data yet. Why does sleep tracking matter, and what should I know before I connect a source?" } }, "navWorkouts": "Workouts", @@ -3798,7 +3810,8 @@ "description": "Recent runs, rides, walks and strength sessions, deduped across Apple Watch and Withings sources.", "emptyState": { "title": "No workouts yet", - "description": "Workouts sync from Apple Health on your iPhone and from Withings. You can also log one by hand." + "description": "Workouts sync from Apple Health on your iPhone and from Withings. You can also log one by hand.", + "coachPrefill": "I haven't logged any workouts yet. Why does tracking them matter, and what should I focus on first?" }, "detail": { "backToList": "Back to workouts", @@ -4678,7 +4691,8 @@ "emptyState": { "title": "No fall data yet", "description": "Detected falls appear here once Apple Health syncs. No falls is the goal." - } + }, + "coachPrefill": "I haven't logged any fall data yet. What does this metric tell me about my health, and how do I lower my fall risk?" }, "navFalls": "Falls", "sixMinuteWalk": { diff --git a/messages/es.json b/messages/es.json index c6c9875ea..8b48d4548 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3516,7 +3516,8 @@ "emptyState": { "title": "Aún no hay valores de VFC", "description": "Los valores de VFC aparecerán aquí en cuanto Apple Watch (o un dispositivo Withings compatible) sincronice las mediciones nocturnas." - } + }, + "coachPrefill": "Aún no he registrado datos de VFC: ¿qué me dice la variabilidad de la frecuencia cardíaca y cómo la registro?" }, "restingHr": { "title": "FC en reposo", @@ -3535,7 +3536,8 @@ "emptyState": { "title": "Aún no hay muestras de SpO₂", "description": "Las muestras de SpO₂ llegan aquí en cuanto un wearable compatible (Apple Watch, Withings ScanWatch) capture una lectura." - } + }, + "coachPrefill": "Aún no he registrado la saturación de oxígeno: ¿qué me dice la SpO₂ y a qué rango debería aspirar?" }, "bodyTemperature": { "title": "Temperatura corporal", @@ -3545,7 +3547,8 @@ "title": "Aún no hay temperatura corporal", "description": "Registra una lectura o deja que tu wearable sincronice una, y la tendencia aparecerá aquí.", "cta": "Registrar temperatura corporal" - } + }, + "coachPrefill": "Aún no he registrado la temperatura corporal: ¿cuál es el rango saludable y qué debo hacer si se desvía?" }, "steps": { "title": "Pasos", @@ -3554,7 +3557,8 @@ "emptyState": { "title": "Aún no hay datos de pasos", "description": "Los totales diarios de pasos aparecerán aquí en cuanto Apple Salud o un dispositivo de actividad sincronice un día completo." - } + }, + "coachPrefill": "Aún no he registrado pasos: ¿cuál es un objetivo diario de pasos razonable y cómo ayuda a mi salud caminar más?" }, "activeEnergy": { "title": "Energía activa", @@ -3563,7 +3567,8 @@ "emptyState": { "title": "Aún no hay datos de energía activa", "description": "Los totales de energía activa aparecerán aquí en cuanto Apple Salud o un dispositivo de actividad Withings sincronice un día completo." - } + }, + "coachPrefill": "Aún no he registrado energía activa: ¿cuál es un objetivo diario razonable y cómo se compara con las calorías totales?" }, "subPage": { "loadError": "No se pudo cargar esta métrica ahora mismo.", @@ -3658,7 +3663,8 @@ "valuesBack": "Volver a Insights", "valuesBackToMetric": "Volver a {metric}", "scoresBack": "Volver a Insights", - "ecgDescription": "Registros de ECG de una derivación de tu dispositivo, cada uno con el resultado del propio dispositivo." + "ecgDescription": "Registros de ECG de una derivación de tu dispositivo, cada uno con el resultado del propio dispositivo.", + "emptyCoachPrefill": "{metric}: aún no he registrado nada aquí. ¿Qué dice esta métrica sobre mi salud y cómo la mejoro?" }, "units": { "steps": "pasos", @@ -3758,27 +3764,32 @@ "bloodPressure": { "title": "Aún no hay registros de presión arterial", "description": "Registra una medición de presión y aquí aparecerán la tendencia, el porcentaje en rango y la clasificación de la OMS.", - "cta": "Registrar presión" + "cta": "Registrar presión", + "coachPrefill": "Aún no he registrado la presión arterial: ¿por qué importa y qué debería saber antes de empezar?" }, "weight": { "title": "Aún no hay registros de peso", "description": "Registra un peso y aparecerán automáticamente la tendencia, la media móvil y la zona objetivo.", - "cta": "Registrar peso" + "cta": "Registrar peso", + "coachPrefill": "Aún no he registrado mi peso: ¿por qué importa y qué debería saber antes de empezar a registrarlo?" }, "pulse": { "title": "Aún no hay registros de pulso", "description": "Registra un pulso y aquí aparecerán pulso en reposo, VFC y VO₂ máx.", - "cta": "Registrar pulso" + "cta": "Registrar pulso", + "coachPrefill": "Aún no he registrado el pulso en reposo: ¿por qué importa y qué debería saber antes de empezar?" }, "bmi": { "title": "IMC aún no calculado", "description": "Cuando haya altura y al menos un registro de peso, aquí aparecerá la tendencia del IMC con las zonas de la OMS.", - "cta": "Registrar peso" + "cta": "Registrar peso", + "coachPrefill": "Aún no he registrado mi peso: ¿por qué me importa el IMC y qué debería saber antes de empezar a seguirlo?" }, "mood": { "title": "Aún no hay entradas de estado de ánimo", "description": "Registra un estado de ánimo y aparecerán la tendencia, la banda de estabilidad y las correlaciones.", - "cta": "Registrar ánimo" + "cta": "Registrar ánimo", + "coachPrefill": "Aún no he registrado mi estado de ánimo: ¿por qué importa registrarlo y cómo debería empezar?" }, "medication": { "title": "Aún no hay medicamentos configurados", @@ -3788,7 +3799,8 @@ "sleep": { "title": "Aún no hay registros de sueño", "description": "Una vez disponibles los datos de sueño (manual o vía Withings / Apple Health), aquí aparecerá el desglose por fases.", - "cta": "Conectar una fuente" + "cta": "Conectar una fuente", + "coachPrefill": "Aún no tengo datos de sueño: ¿por qué importa registrar el sueño y qué debería saber antes de conectar una fuente?" } }, "navWorkouts": "Entrenamientos", @@ -3798,7 +3810,8 @@ "description": "Carreras, salidas en bici, paseos y sesiones de fuerza recientes, deduplicados entre Apple Watch y Withings.", "emptyState": { "title": "Aún no hay entrenamientos", - "description": "Los entrenamientos llegan desde Apple Salud en el iPhone y desde Withings. También puedes registrar uno a mano." + "description": "Los entrenamientos llegan desde Apple Salud en el iPhone y desde Withings. También puedes registrar uno a mano.", + "coachPrefill": "Aún no he registrado entrenamientos: ¿por qué importa registrarlos y en qué debería centrarme primero?" }, "detail": { "backToList": "Volver a entrenamientos", @@ -4678,7 +4691,8 @@ "emptyState": { "title": "Aún no hay datos de caídas", "description": "Las caídas detectadas aparecen aquí cuando Apple Health se sincroniza. Cero caídas es el objetivo." - } + }, + "coachPrefill": "Aún no he registrado datos de caídas: ¿qué dice esta métrica sobre mi salud y cómo reduzco mi riesgo de caídas?" }, "navFalls": "Caídas", "sixMinuteWalk": { diff --git a/messages/fr.json b/messages/fr.json index 95779eaa4..20068bcf7 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3516,7 +3516,8 @@ "emptyState": { "title": "Aucune mesure de VFC", "description": "Les mesures de VFC apparaîtront ici dès que l'Apple Watch (ou un appareil Withings compatible) synchronisera des relevés nocturnes." - } + }, + "coachPrefill": "Je n’ai pas encore enregistré de données de VFC. Que m’apprend la variabilité de la fréquence cardiaque, et comment la mesurer ?" }, "restingHr": { "title": "FC au repos", @@ -3535,7 +3536,8 @@ "emptyState": { "title": "Aucune mesure SpO₂", "description": "Les mesures SpO₂ apparaîtront dès qu'un wearable compatible (Apple Watch, Withings ScanWatch) en capturera une." - } + }, + "coachPrefill": "Je n’ai pas encore enregistré de saturation en oxygène. Que m’apprend la SpO₂, et quelle plage viser ?" }, "bodyTemperature": { "title": "Température corporelle", @@ -3545,7 +3547,8 @@ "title": "Aucune température corporelle", "description": "Enregistrez une mesure ou laissez votre wearable en synchroniser une, et la tendance apparaîtra ici.", "cta": "Enregistrer la température" - } + }, + "coachPrefill": "Je n’ai pas encore enregistré de température corporelle. Quelle est la plage normale, et que faire si elle s’en écarte ?" }, "steps": { "title": "Pas", @@ -3554,7 +3557,8 @@ "emptyState": { "title": "Aucune donnée de pas", "description": "Les totaux de pas quotidiens apparaîtront ici dès qu'Apple Santé ou un appareil d'activité synchronise une journée complète." - } + }, + "coachPrefill": "Je n’ai pas encore enregistré de pas. Quel objectif quotidien de pas est raisonnable, et en quoi marcher davantage aide-t-il ma santé ?" }, "activeEnergy": { "title": "Énergie active", @@ -3563,7 +3567,8 @@ "emptyState": { "title": "Aucune donnée d'énergie active", "description": "Les totaux d'énergie active apparaîtront dès qu'Apple Santé ou un appareil Withings activité synchronise une journée complète." - } + }, + "coachPrefill": "Je n’ai pas encore enregistré d’énergie active. Quel objectif quotidien est raisonnable, et comment se compare-t-il aux calories totales ?" }, "subPage": { "loadError": "Impossible de charger cette mesure pour le moment.", @@ -3658,7 +3663,8 @@ "valuesBack": "Retour aux Insights", "valuesBackToMetric": "Retour à {metric}", "scoresBack": "Retour aux Insights", - "ecgDescription": "Enregistrements ECG à dérivation unique de votre appareil, chacun accompagné du résultat de l'appareil." + "ecgDescription": "Enregistrements ECG à dérivation unique de votre appareil, chacun accompagné du résultat de l'appareil.", + "emptyCoachPrefill": "{metric} : je n’ai encore rien enregistré ici. Que dit cette mesure sur ma santé, et comment l’améliorer ?" }, "units": { "steps": "pas", @@ -3758,27 +3764,32 @@ "bloodPressure": { "title": "Aucune mesure de tension artérielle", "description": "Enregistrez une mesure de tension : la tendance, la part dans la cible et la classification OMS s'afficheront ici.", - "cta": "Enregistrer une tension" + "cta": "Enregistrer une tension", + "coachPrefill": "Je n’ai pas encore enregistré de tension artérielle. Pourquoi est-ce important, et que dois-je savoir avant de commencer ?" }, "weight": { "title": "Aucune mesure de poids", "description": "Enregistrez un poids : la tendance, la moyenne glissante et la zone cible apparaîtront automatiquement.", - "cta": "Enregistrer un poids" + "cta": "Enregistrer un poids", + "coachPrefill": "Je n’ai pas encore enregistré de poids. Pourquoi est-ce important, et que dois-je savoir avant de commencer le suivi ?" }, "pulse": { "title": "Aucune mesure de pouls", "description": "Enregistrez un pouls : pouls au repos, VFC et VO₂ max apparaîtront dans cette vue.", - "cta": "Enregistrer un pouls" + "cta": "Enregistrer un pouls", + "coachPrefill": "Je n’ai pas encore enregistré de pouls au repos. Pourquoi est-ce important, et que dois-je savoir avant de commencer ?" }, "bmi": { "title": "IMC pas encore calculé", "description": "Dès qu'une taille et au moins un poids sont enregistrés, la tendance IMC avec les zones OMS s'affichera ici.", - "cta": "Enregistrer un poids" + "cta": "Enregistrer un poids", + "coachPrefill": "Je n’ai pas encore enregistré de poids. Pourquoi l’IMC compte-t-il pour moi, et que dois-je savoir avant de le suivre ?" }, "mood": { "title": "Aucune entrée d'humeur", "description": "Enregistrez une humeur : la tendance, la bande de stabilité et les corrélations s'afficheront.", - "cta": "Enregistrer une humeur" + "cta": "Enregistrer une humeur", + "coachPrefill": "Je n’ai pas encore enregistré d’humeur. Pourquoi suivre mon humeur, et comment commencer ?" }, "medication": { "title": "Aucun médicament configuré", @@ -3788,7 +3799,8 @@ "sleep": { "title": "Aucune donnée de sommeil", "description": "Dès que des données de sommeil sont disponibles (manuel ou via Withings / Apple Health), la répartition par stade apparaîtra ici.", - "cta": "Connecter une source" + "cta": "Connecter une source", + "coachPrefill": "Je n’ai pas encore de données de sommeil. Pourquoi suivre mon sommeil, et que dois-je savoir avant de connecter une source ?" } }, "navWorkouts": "Entraînements", @@ -3798,7 +3810,8 @@ "description": "Courses, sorties à vélo, marches et séances de musculation récentes, dédupliquées entre Apple Watch et Withings.", "emptyState": { "title": "Aucun entraînement", - "description": "Vos entraînements arrivent d'Apple Santé sur l'iPhone et de Withings. Vous pouvez aussi en saisir un à la main." + "description": "Vos entraînements arrivent d'Apple Santé sur l'iPhone et de Withings. Vous pouvez aussi en saisir un à la main.", + "coachPrefill": "Je n’ai pas encore enregistré d’entraînement. Pourquoi les suivre, et sur quoi me concentrer en premier ?" }, "detail": { "backToList": "Retour aux entraînements", @@ -4678,7 +4691,8 @@ "emptyState": { "title": "Pas encore de données de chutes", "description": "Les chutes détectées apparaissent ici dès qu'Apple Santé se synchronise. L'objectif est zéro chute." - } + }, + "coachPrefill": "Je n’ai pas encore enregistré de données de chute. Que dit cette mesure sur ma santé, et comment réduire mon risque de chute ?" }, "navFalls": "Chutes", "sixMinuteWalk": { diff --git a/messages/it.json b/messages/it.json index 1bc25ec4c..1b7c1560b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3516,7 +3516,8 @@ "emptyState": { "title": "Ancora nessun valore HRV", "description": "I valori HRV appariranno qui non appena Apple Watch (o un dispositivo Withings compatibile) sincronizza le misurazioni notturne." - } + }, + "coachPrefill": "Non ho ancora registrato dati HRV. Cosa mi dice la variabilità della frequenza cardiaca e come la rilevo?" }, "restingHr": { "title": "FC a riposo", @@ -3535,7 +3536,8 @@ "emptyState": { "title": "Ancora nessun campione SpO₂", "description": "I campioni SpO₂ appariranno qui non appena un wearable compatibile (Apple Watch, Withings ScanWatch) cattura una lettura." - } + }, + "coachPrefill": "Non ho ancora registrato la saturazione di ossigeno. Cosa mi dice la SpO₂ e a quale intervallo dovrei puntare?" }, "bodyTemperature": { "title": "Temperatura corporea", @@ -3545,7 +3547,8 @@ "title": "Ancora nessuna temperatura corporea", "description": "Registra una misura o lascia che il tuo wearable ne sincronizzi una: l'andamento apparirà qui.", "cta": "Registra temperatura corporea" - } + }, + "coachPrefill": "Non ho ancora registrato la temperatura corporea. Qual è l’intervallo sano e cosa dovrei fare se si discosta?" }, "steps": { "title": "Passi", @@ -3554,7 +3557,8 @@ "emptyState": { "title": "Ancora nessun dato sui passi", "description": "I totali giornalieri dei passi appariranno qui non appena Apple Salute o un dispositivo di attività sincronizza un'intera giornata." - } + }, + "coachPrefill": "Non ho ancora registrato passi. Qual è un obiettivo giornaliero di passi ragionevole e in che modo camminare di più aiuta la mia salute?" }, "activeEnergy": { "title": "Energia attiva", @@ -3563,7 +3567,8 @@ "emptyState": { "title": "Ancora nessun dato di energia attiva", "description": "I totali di energia attiva appariranno qui non appena Apple Salute o un dispositivo di attività Withings sincronizza un'intera giornata." - } + }, + "coachPrefill": "Non ho ancora registrato energia attiva. Qual è un obiettivo giornaliero ragionevole e come si confronta con le calorie totali?" }, "subPage": { "loadError": "Impossibile caricare questa metrica al momento.", @@ -3658,7 +3663,8 @@ "valuesBack": "Torna a Insights", "valuesBackToMetric": "Torna a {metric}", "scoresBack": "Torna a Insights", - "ecgDescription": "Registrazioni ECG a singola derivazione dal tuo dispositivo, ciascuna con il risultato del dispositivo." + "ecgDescription": "Registrazioni ECG a singola derivazione dal tuo dispositivo, ciascuna con il risultato del dispositivo.", + "emptyCoachPrefill": "{metric}: non ho ancora registrato nulla qui. Cosa dice questo valore sulla mia salute e come lo miglioro?" }, "units": { "steps": "passi", @@ -3758,27 +3764,32 @@ "bloodPressure": { "title": "Nessuna misurazione della pressione", "description": "Registra una misurazione e qui appariranno l'andamento, la percentuale in target e la classificazione OMS.", - "cta": "Registra pressione" + "cta": "Registra pressione", + "coachPrefill": "Non ho ancora registrato la pressione arteriosa. Perché è importante e cosa dovrei sapere prima di iniziare?" }, "weight": { "title": "Nessuna misurazione del peso", "description": "Registra un peso: andamento, media mobile e fascia obiettivo appariranno automaticamente.", - "cta": "Registra peso" + "cta": "Registra peso", + "coachPrefill": "Non ho ancora registrato il peso. Perché è importante e cosa dovrei sapere prima di iniziare a monitorarlo?" }, "pulse": { "title": "Nessuna misurazione del polso", "description": "Registra una misurazione: polso a riposo, HRV e VO₂ max appariranno in questa vista.", - "cta": "Registra polso" + "cta": "Registra polso", + "coachPrefill": "Non ho ancora registrato il polso a riposo. Perché è importante e cosa dovrei sapere prima di iniziare?" }, "bmi": { "title": "BMI non ancora calcolato", "description": "Una volta inseriti altezza e almeno un peso, qui apparirà l'andamento del BMI con le zone OMS.", - "cta": "Registra peso" + "cta": "Registra peso", + "coachPrefill": "Non ho ancora registrato il peso. Perché il BMI conta per me e cosa dovrei sapere prima di iniziare a monitorarlo?" }, "mood": { "title": "Nessuna voce d'umore", "description": "Registra un umore: appariranno andamento, fascia di stabilità e correlazioni.", - "cta": "Registra umore" + "cta": "Registra umore", + "coachPrefill": "Non ho ancora registrato l’umore. Perché conta monitorarlo e come dovrei iniziare?" }, "medication": { "title": "Nessun farmaco configurato", @@ -3788,7 +3799,8 @@ "sleep": { "title": "Nessuna voce di sonno", "description": "Quando saranno disponibili dati sul sonno (manuale o tramite Withings / Apple Health), qui apparirà la suddivisione per fase.", - "cta": "Collega una fonte" + "cta": "Collega una fonte", + "coachPrefill": "Non ho ancora dati sul sonno. Perché conta monitorare il sonno e cosa dovrei sapere prima di collegare una fonte?" } }, "navWorkouts": "Allenamenti", @@ -3798,7 +3810,8 @@ "description": "Corse, uscite in bici, camminate e sessioni di forza recenti, deduplicati tra Apple Watch e Withings.", "emptyState": { "title": "Nessun allenamento", - "description": "Gli allenamenti arrivano da Salute di Apple sull'iPhone e da Withings. Puoi anche registrarne uno a mano." + "description": "Gli allenamenti arrivano da Salute di Apple sull'iPhone e da Withings. Puoi anche registrarne uno a mano.", + "coachPrefill": "Non ho ancora registrato allenamenti. Perché conta monitorarli e su cosa dovrei concentrarmi per primo?" }, "detail": { "backToList": "Torna agli allenamenti", @@ -4678,7 +4691,8 @@ "emptyState": { "title": "Nessun dato sulle cadute", "description": "Le cadute rilevate compaiono qui quando Apple Health si sincronizza. Nessuna caduta è l'obiettivo." - } + }, + "coachPrefill": "Non ho ancora registrato dati sulle cadute. Cosa dice questo valore sulla mia salute e come riduco il rischio di cadute?" }, "navFalls": "Cadute", "sixMinuteWalk": { diff --git a/messages/ko.json b/messages/ko.json index 5aeef7e5d..e230435d0 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -3516,7 +3516,8 @@ "emptyState": { "title": "아직 심박변이도 기록이 없어요", "description": "Apple Watch(또는 호환 Withings 기기)가 밤마다 측정값을 동기화하기 시작하면 심박변이도 기록이 여기에 쌓여요." - } + }, + "coachPrefill": "아직 심박변이도 데이터를 기록한 적이 없어요. 심박변이도는 무엇을 알려주고, 어떻게 측정하나요?" }, "restingHr": { "title": "안정 시 심박수", @@ -3535,7 +3536,8 @@ "emptyState": { "title": "아직 SpO₂ 기록이 없어요", "description": "호환 웨어러블(Apple Watch, Withings ScanWatch)이 측정하면 SpO₂ 기록이 여기에 쌓여요." - } + }, + "coachPrefill": "아직 산소포화도를 기록한 적이 없어요. SpO₂는 무엇을 알려주고, 어느 범위를 목표로 해야 하나요?" }, "bodyTemperature": { "title": "체온", @@ -3545,7 +3547,8 @@ "title": "아직 체온 기록이 없어요", "description": "체온을 기록하거나 웨어러블이 동기화하면 여기에 추이가 나타나요.", "cta": "체온 기록" - } + }, + "coachPrefill": "아직 체온을 기록한 적이 없어요. 건강한 범위는 어느 정도이고, 벗어나면 어떻게 해야 하나요?" }, "steps": { "title": "걸음 수", @@ -3554,7 +3557,8 @@ "emptyState": { "title": "아직 걸음 수 데이터가 없어요", "description": "Apple Health나 활동 기기가 하루치 움직임을 동기화하면 여기에 하루 걸음 수가 나타나요." - } + }, + "coachPrefill": "아직 걸음 수를 기록한 적이 없어요. 적당한 하루 걸음 목표는 얼마이고, 더 많이 걸으면 건강에 어떻게 도움이 되나요?" }, "activeEnergy": { "title": "활동 에너지", @@ -3563,7 +3567,8 @@ "emptyState": { "title": "아직 활동 에너지 데이터가 없어요", "description": "Apple Health나 Withings 활동 기기가 하루치 움직임을 동기화하면 여기에 활동 에너지 합계가 나타나요." - } + }, + "coachPrefill": "아직 활동 에너지를 기록한 적이 없어요. 적당한 하루 목표는 얼마이고, 총 칼로리와는 어떻게 비교되나요?" }, "subPage": { "loadError": "지금은 이 지표를 불러오지 못했어요.", @@ -3658,7 +3663,8 @@ "valuesBack": "인사이트로 돌아가기", "valuesBackToMetric": "돌아가기: {metric}", "scoresBack": "인사이트로 돌아가기", - "ecgDescription": "기기에서 기록한 단일 유도 ECG로, 각각 기기가 낸 결과가 함께 있어요." + "ecgDescription": "기기에서 기록한 단일 유도 ECG로, 각각 기기가 낸 결과가 함께 있어요.", + "emptyCoachPrefill": "{metric}: 아직 여기에 기록한 게 없어요. 이 지표는 내 건강에 대해 무엇을 알려주고, 어떻게 개선하나요?" }, "units": { "steps": "걸음", @@ -3758,27 +3764,32 @@ "bloodPressure": { "title": "아직 혈압 기록이 없어요", "description": "혈압을 기록하면 추이 차트, 정상 범위 비율, WHO 분류가 여기에 나타나요.", - "cta": "혈압 기록" + "cta": "혈압 기록", + "coachPrefill": "아직 혈압을 기록한 적이 없어요. 혈압이 왜 중요하고, 시작하기 전에 무엇을 알아야 하나요?" }, "weight": { "title": "아직 체중 기록이 없어요", "description": "체중을 기록하면 추이, 이동 평균, 목표 구간이 자동으로 나타나요.", - "cta": "체중 기록" + "cta": "체중 기록", + "coachPrefill": "아직 체중을 기록한 적이 없어요. 체중이 왜 중요하고, 기록을 시작하기 전에 무엇을 알아야 하나요?" }, "pulse": { "title": "아직 맥박 기록이 없어요", "description": "맥박을 기록하면 안정 시 맥박, 심박변이도, VO₂ max가 이 화면에 나타나요.", - "cta": "맥박 기록" + "cta": "맥박 기록", + "coachPrefill": "아직 안정 시 맥박을 기록한 적이 없어요. 맥박이 왜 중요하고, 시작하기 전에 무엇을 알아야 하나요?" }, "bmi": { "title": "아직 BMI를 계산하지 못했어요", "description": "키와 체중 기록이 하나 이상 있으면 WHO 구간과 함께 BMI 추이가 여기에 나타나요.", - "cta": "체중 기록" + "cta": "체중 기록", + "coachPrefill": "아직 체중을 기록한 적이 없어요. BMI가 내게 왜 중요하고, 기록을 시작하기 전에 무엇을 알아야 하나요?" }, "mood": { "title": "아직 기분 기록이 없어요", "description": "기분을 기록하면 추이, 안정성 구간, 상관관계가 나타나요.", - "cta": "기분 기록" + "cta": "기분 기록", + "coachPrefill": "아직 기분을 기록한 적이 없어요. 기분 기록이 왜 중요하고, 어떻게 시작하면 좋을까요?" }, "medication": { "title": "아직 등록한 약이 없어요", @@ -3788,7 +3799,8 @@ "sleep": { "title": "아직 수면 기록이 없어요", "description": "수면 데이터가 들어오면(직접 기록하거나 Withings / Apple Health를 통해) 밤별 단계 구성이 여기에 나타나요.", - "cta": "소스 연결" + "cta": "소스 연결", + "coachPrefill": "아직 수면 데이터가 없어요. 수면 기록이 왜 중요하고, 데이터 소스를 연결하기 전에 무엇을 알아야 하나요?" } }, "navWorkouts": "운동", @@ -3798,7 +3810,8 @@ "description": "최근 달리기, 자전거, 걷기, 근력 운동 세션이에요. Apple Watch와 Withings 소스 사이 중복은 정리했어요.", "emptyState": { "title": "아직 운동 기록이 없어요", - "description": "운동은 iPhone의 Apple Health와 Withings에서 동기화돼요. 직접 기록할 수도 있어요." + "description": "운동은 iPhone의 Apple Health와 Withings에서 동기화돼요. 직접 기록할 수도 있어요.", + "coachPrefill": "아직 운동을 기록한 적이 없어요. 운동 기록이 왜 중요하고, 무엇부터 신경 써야 하나요?" }, "detail": { "backToList": "운동으로 돌아가기", @@ -4678,7 +4691,8 @@ "emptyState": { "title": "아직 낙상 데이터가 없어요", "description": "Apple Health가 동기화하면 감지된 낙상이 여기에 표시돼요. 낙상이 없는 게 가장 좋아요." - } + }, + "coachPrefill": "아직 낙상 데이터를 기록한 적이 없어요. 이 지표는 내 건강에 대해 무엇을 알려주고, 낙상 위험은 어떻게 줄이나요?" }, "navFalls": "낙상", "sixMinuteWalk": { diff --git a/messages/nl.json b/messages/nl.json index e0a3f8535..2d793cfc0 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -3516,7 +3516,8 @@ "emptyState": { "title": "Nog geen HRV-metingen", "description": "HRV-metingen verschijnen hier zodra Apple Watch (of een compatibel Withings-apparaat) nachtelijke metingen begint te synchroniseren." - } + }, + "coachPrefill": "Ik heb nog geen HRV-gegevens vastgelegd. Wat zegt hartslagvariabiliteit me, en hoe leg ik die vast?" }, "restingHr": { "title": "Rusthartslag", @@ -3535,7 +3536,8 @@ "emptyState": { "title": "Nog geen SpO₂-metingen", "description": "SpO₂-metingen verschijnen hier zodra een compatibele wearable (Apple Watch, Withings ScanWatch) een meting vastlegt." - } + }, + "coachPrefill": "Ik heb nog geen zuurstofverzadiging vastgelegd. Wat zegt SpO₂ me, en naar welk bereik moet ik streven?" }, "bodyTemperature": { "title": "Lichaamstemperatuur", @@ -3545,7 +3547,8 @@ "title": "Nog geen lichaamstemperatuur", "description": "Leg een temperatuurmeting vast of laat je wearable er een synchroniseren, dan verschijnt de trend hier.", "cta": "Lichaamstemperatuur vastleggen" - } + }, + "coachPrefill": "Ik heb nog geen lichaamstemperatuur vastgelegd. Wat is het gezonde bereik, en wat moet ik doen als die afwijkt?" }, "steps": { "title": "Stappen", @@ -3554,7 +3557,8 @@ "emptyState": { "title": "Nog geen stappengegevens", "description": "Dagtotalen van je stappen verschijnen hier zodra Apple Health of een activiteitsapparaat een volledige dag beweging synchroniseert." - } + }, + "coachPrefill": "Ik heb nog geen stappen vastgelegd. Wat is een redelijk dagelijks stappendoel, en hoe helpt meer lopen mijn gezondheid?" }, "activeEnergy": { "title": "Actieve energie", @@ -3563,7 +3567,8 @@ "emptyState": { "title": "Nog geen gegevens over actieve energie", "description": "Dagtotalen van actieve energie verschijnen hier zodra Apple Health of een Withings-activiteitsapparaat een volledige dag beweging synchroniseert." - } + }, + "coachPrefill": "Ik heb nog geen actieve energie vastgelegd. Wat is een redelijk dagdoel, en hoe verhoudt het zich tot het totale aantal calorieën?" }, "subPage": { "loadError": "Deze meetwaarde kan nu niet worden geladen.", @@ -3658,7 +3663,8 @@ "valuesBack": "Terug naar inzichten", "valuesBackToMetric": "Terug naar {metric}", "scoresBack": "Terug naar inzichten", - "ecgDescription": "Eenkanaals-ECG-opnamen van je apparaat, elk met de eigen uitslag van het apparaat." + "ecgDescription": "Eenkanaals-ECG-opnamen van je apparaat, elk met de eigen uitslag van het apparaat.", + "emptyCoachPrefill": "{metric}: ik heb hier nog niets vastgelegd. Wat zegt deze waarde over mijn gezondheid, en hoe verbeter ik die?" }, "units": { "steps": "stappen", @@ -3758,27 +3764,32 @@ "bloodPressure": { "title": "Nog geen bloeddrukmetingen", "description": "Leg een bloeddrukmeting vast, dan verschijnen hier de trendgrafiek, het aandeel binnen het bereik en de WHO-classificatie.", - "cta": "Bloeddruk vastleggen" + "cta": "Bloeddruk vastleggen", + "coachPrefill": "Ik heb nog geen bloeddruk vastgelegd. Waarom is die belangrijk, en wat moet ik weten voordat ik begin?" }, "weight": { "title": "Nog geen gewichtsmetingen", "description": "Leg je gewicht vast, dan verschijnen de trend, het voortschrijdend gemiddelde en de doelzone automatisch.", - "cta": "Gewicht vastleggen" + "cta": "Gewicht vastleggen", + "coachPrefill": "Ik heb nog geen gewicht vastgelegd. Waarom is dat belangrijk, en wat moet ik weten voordat ik het ga bijhouden?" }, "pulse": { "title": "Nog geen hartslagmetingen", "description": "Leg een hartslagmeting vast, dan verschijnen rusthartslag, HRV en VO₂max in deze weergave.", - "cta": "Hartslag vastleggen" + "cta": "Hartslag vastleggen", + "coachPrefill": "Ik heb nog geen rustpols vastgelegd. Waarom is die belangrijk, en wat moet ik weten voordat ik begin?" }, "bmi": { "title": "BMI nog niet berekend", "description": "Zodra je lengte en minstens één gewichtsmeting bekend zijn, verschijnt hier de BMI-trend met de WHO-zones.", - "cta": "Gewicht vastleggen" + "cta": "Gewicht vastleggen", + "coachPrefill": "Ik heb nog geen gewicht vastgelegd. Waarom is BMI belangrijk voor mij, en wat moet ik weten voordat ik het ga bijhouden?" }, "mood": { "title": "Nog geen stemmingsregistraties", "description": "Leg je stemming vast, dan verschijnen de trend, de stabiliteitszone en de correlaties.", - "cta": "Stemming vastleggen" + "cta": "Stemming vastleggen", + "coachPrefill": "Ik heb nog geen stemming vastgelegd. Waarom is stemming bijhouden belangrijk, en hoe begin ik?" }, "medication": { "title": "Nog geen medicatie ingesteld", @@ -3788,7 +3799,8 @@ "sleep": { "title": "Nog geen slaapregistraties", "description": "Zodra er slaapgegevens binnenkomen (handmatig of via Withings / Apple Health), verschijnt hier de verdeling over slaapfasen per nacht.", - "cta": "Bron koppelen" + "cta": "Bron koppelen", + "coachPrefill": "Ik heb nog geen slaapgegevens. Waarom is slaap bijhouden belangrijk, en wat moet ik weten voordat ik een bron koppel?" } }, "navWorkouts": "Workouts", @@ -3798,7 +3810,8 @@ "description": "Recente hardloop-, fiets-, wandel- en krachttrainingen, ontdubbeld over bronnen van Apple Watch en Withings.", "emptyState": { "title": "Nog geen workouts", - "description": "Workouts worden gesynchroniseerd vanuit Apple Health op je iPhone en vanuit Withings. Je kunt er ook zelf een vastleggen." + "description": "Workouts worden gesynchroniseerd vanuit Apple Health op je iPhone en vanuit Withings. Je kunt er ook zelf een vastleggen.", + "coachPrefill": "Ik heb nog geen workouts vastgelegd. Waarom is bijhouden belangrijk, en waar moet ik me eerst op richten?" }, "detail": { "backToList": "Terug naar workouts", @@ -4678,7 +4691,8 @@ "emptyState": { "title": "Nog geen valgegevens", "description": "Gedetecteerde valpartijen verschijnen hier zodra Apple Health synchroniseert. Geen valpartijen is het doel." - } + }, + "coachPrefill": "Ik heb nog geen valgegevens vastgelegd. Wat zegt deze waarde over mijn gezondheid, en hoe verklein ik mijn valrisico?" }, "navFalls": "Valpartijen", "sixMinuteWalk": { diff --git a/messages/pl.json b/messages/pl.json index d35c7b385..6a0a61c38 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3516,7 +3516,8 @@ "emptyState": { "title": "Brak wartości HRV", "description": "Wartości HRV pojawią się tutaj, gdy Apple Watch (lub zgodne urządzenie Withings) zsynchronizuje pomiary nocne." - } + }, + "coachPrefill": "Nie zapisałem jeszcze danych HRV. Co mówi mi zmienność rytmu serca i jak ją rejestrować?" }, "restingHr": { "title": "Tętno spoczynkowe", @@ -3535,7 +3536,8 @@ "emptyState": { "title": "Brak próbek SpO₂", "description": "Próbki SpO₂ trafią tutaj, gdy zgodne wearable (Apple Watch, Withings ScanWatch) wykona pomiar." - } + }, + "coachPrefill": "Nie zapisałem jeszcze saturacji tlenu. Co mówi mi SpO₂ i do jakiego zakresu powinienem dążyć?" }, "bodyTemperature": { "title": "Temperatura ciała", @@ -3545,7 +3547,8 @@ "title": "Brak temperatury ciała", "description": "Zarejestruj pomiar lub pozwól wearable zsynchronizować jeden, a trend pojawi się tutaj.", "cta": "Zarejestruj temperaturę ciała" - } + }, + "coachPrefill": "Nie zapisałem jeszcze temperatury ciała. Jaki zakres jest prawidłowy i co powinienem zrobić, gdy od niego odbiega?" }, "steps": { "title": "Kroki", @@ -3554,7 +3557,8 @@ "emptyState": { "title": "Brak danych o krokach", "description": "Dzienne sumy kroków pojawią się tutaj, gdy Apple Zdrowie lub urządzenie aktywności zsynchronizuje pełny dzień." - } + }, + "coachPrefill": "Nie zapisałem jeszcze kroków. Jaki dzienny cel kroków jest rozsądny i jak więcej chodzenia pomaga mojemu zdrowiu?" }, "activeEnergy": { "title": "Energia czynna", @@ -3563,7 +3567,8 @@ "emptyState": { "title": "Brak danych o energii czynnej", "description": "Sumy energii czynnej pojawią się tutaj, gdy Apple Zdrowie lub urządzenie Withings zsynchronizuje pełny dzień." - } + }, + "coachPrefill": "Nie zapisałem jeszcze energii czynnej. Jaki dzienny cel jest rozsądny i jak ma się on do łącznej liczby kalorii?" }, "subPage": { "loadError": "Nie udało się teraz wczytać tej metryki.", @@ -3658,7 +3663,8 @@ "valuesBack": "Powrót do Insights", "valuesBackToMetric": "Powrót do {metric}", "scoresBack": "Powrót do Insights", - "ecgDescription": "Zapisy EKG jednoodprowadzeniowe z Twojego urządzenia, każdy z wynikiem samego urządzenia." + "ecgDescription": "Zapisy EKG jednoodprowadzeniowe z Twojego urządzenia, każdy z wynikiem samego urządzenia.", + "emptyCoachPrefill": "{metric}: nie zapisałem tu jeszcze niczego. Co ten wskaźnik mówi o moim zdrowiu i jak mogę go poprawić?" }, "units": { "steps": "kroki", @@ -3758,27 +3764,32 @@ "bloodPressure": { "title": "Brak pomiarów ciśnienia krwi", "description": "Zarejestruj pomiar ciśnienia, a pojawi się tu trend, udział w zakresie docelowym i klasyfikacja WHO.", - "cta": "Zarejestruj ciśnienie" + "cta": "Zarejestruj ciśnienie", + "coachPrefill": "Nie zapisałem jeszcze ciśnienia krwi. Dlaczego ma ono znaczenie i co powinienem wiedzieć, zanim zacznę?" }, "weight": { "title": "Brak pomiarów wagi", "description": "Zarejestruj wagę, a trend, średnia krocząca i zakres docelowy pojawią się automatycznie.", - "cta": "Zarejestruj wagę" + "cta": "Zarejestruj wagę", + "coachPrefill": "Nie zapisałem jeszcze masy ciała. Dlaczego ma ona znaczenie i co powinienem wiedzieć, zanim zacznę ją śledzić?" }, "pulse": { "title": "Brak pomiarów tętna", "description": "Zarejestruj tętno, a w tym widoku pojawi się tętno spoczynkowe, HRV i VO₂ max.", - "cta": "Zarejestruj tętno" + "cta": "Zarejestruj tętno", + "coachPrefill": "Nie zapisałem jeszcze tętna spoczynkowego. Dlaczego ma ono znaczenie i co powinienem wiedzieć, zanim zacznę?" }, "bmi": { "title": "BMI jeszcze nie obliczone", "description": "Po wpisaniu wzrostu i co najmniej jednej wagi pojawi się tu trend BMI z zakresami WHO.", - "cta": "Zarejestruj wagę" + "cta": "Zarejestruj wagę", + "coachPrefill": "Nie zapisałem jeszcze masy ciała. Dlaczego BMI ma dla mnie znaczenie i co powinienem wiedzieć, zanim zacznę je śledzić?" }, "mood": { "title": "Brak wpisów nastroju", "description": "Zarejestruj nastrój, a pojawi się trend, pasmo stabilności i korelacje.", - "cta": "Zarejestruj nastrój" + "cta": "Zarejestruj nastrój", + "coachPrefill": "Nie zapisałem jeszcze nastroju. Dlaczego warto go śledzić i jak powinienem zacząć?" }, "medication": { "title": "Brak skonfigurowanych leków", @@ -3788,7 +3799,8 @@ "sleep": { "title": "Brak danych o śnie", "description": "Gdy pojawią się dane o śnie (ręcznie lub przez Withings / Apple Health), wyświetli się tu rozkład faz na noc.", - "cta": "Połącz źródło" + "cta": "Połącz źródło", + "coachPrefill": "Nie mam jeszcze danych o śnie. Dlaczego warto śledzić sen i co powinienem wiedzieć, zanim podłączę źródło?" } }, "navWorkouts": "Treningi", @@ -3798,7 +3810,8 @@ "description": "Ostatnie biegi, jazdy, marsze i treningi siłowe, zdeduplikowane między Apple Watch a Withings.", "emptyState": { "title": "Brak treningów", - "description": "Treningi trafiają tu z Apple Zdrowie na iPhonie i z Withings. Możesz też wpisać trening ręcznie." + "description": "Treningi trafiają tu z Apple Zdrowie na iPhonie i z Withings. Możesz też wpisać trening ręcznie.", + "coachPrefill": "Nie zapisałem jeszcze treningów. Dlaczego warto je śledzić i na czym powinienem się najpierw skupić?" }, "detail": { "backToList": "Powrót do treningów", @@ -4678,7 +4691,8 @@ "emptyState": { "title": "Brak danych o upadkach", "description": "Wykryte upadki pojawią się tutaj, gdy Apple Health się zsynchronizuje. Celem jest brak upadków." - } + }, + "coachPrefill": "Nie zapisałem jeszcze danych o upadkach. Co ten wskaźnik mówi o moim zdrowiu i jak mogę zmniejszyć ryzyko upadku?" }, "navFalls": "Upadki", "sixMinuteWalk": { diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 05e875766..02d0f6eb3 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -3516,7 +3516,8 @@ "emptyState": { "title": "Ainda não há amostras de VFC", "description": "As amostras de VFC chegam aqui assim que o Apple Watch (ou um dispositivo Withings compatível) começa a sincronizar leituras noturnas." - } + }, + "coachPrefill": "Ainda não registrei dados de VFC. O que a variabilidade da frequência cardíaca me diz, e como eu a registro?" }, "restingHr": { "title": "Frequência cardíaca em repouso", @@ -3535,7 +3536,8 @@ "emptyState": { "title": "Ainda não há amostras de SpO₂", "description": "As amostras de SpO₂ chegam aqui assim que um wearable compatível (Apple Watch, Withings ScanWatch) captura uma leitura." - } + }, + "coachPrefill": "Ainda não registrei saturação de oxigênio. O que a SpO₂ me diz, e qual faixa devo buscar?" }, "bodyTemperature": { "title": "Temperatura corporal", @@ -3545,7 +3547,8 @@ "title": "Ainda não há temperatura corporal", "description": "Registre uma leitura de temperatura corporal ou deixe seu wearable sincronizar uma, e a tendência aparecerá aqui.", "cta": "Registrar temperatura corporal" - } + }, + "coachPrefill": "Ainda não registrei temperatura corporal. Qual é a faixa saudável, e o que devo fazer se ela se desviar?" }, "steps": { "title": "Passos", @@ -3554,7 +3557,8 @@ "emptyState": { "title": "Ainda não há dados de passos", "description": "Os totais diários de passos aparecem aqui assim que o Apple Health ou um dispositivo de atividade sincroniza um dia de movimento." - } + }, + "coachPrefill": "Ainda não registrei passos. Qual é uma meta diária de passos razoável, e como caminhar mais ajuda minha saúde?" }, "activeEnergy": { "title": "Energia ativa", @@ -3563,7 +3567,8 @@ "emptyState": { "title": "Ainda não há dados de energia ativa", "description": "Os totais de energia ativa chegam aqui assim que o Apple Health ou um dispositivo de atividade Withings sincroniza um dia de movimento." - } + }, + "coachPrefill": "Ainda não registrei energia ativa. Qual é uma meta diária razoável, e como ela se compara ao total de calorias?" }, "subPage": { "loadError": "Não foi possível carregar esta métrica agora.", @@ -3658,7 +3663,8 @@ "valuesBack": "Voltar aos insights", "valuesBackToMetric": "Voltar para {metric}", "scoresBack": "Voltar aos insights", - "ecgDescription": "Registros de ECG de uma derivação do seu dispositivo, cada um com o resultado do próprio dispositivo." + "ecgDescription": "Registros de ECG de uma derivação do seu dispositivo, cada um com o resultado do próprio dispositivo.", + "emptyCoachPrefill": "{metric}: ainda não registrei nada aqui. O que essa métrica me diz sobre minha saúde, e como eu a melhoro?" }, "units": { "steps": "passos", @@ -3758,27 +3764,32 @@ "bloodPressure": { "title": "Ainda não há registros de pressão arterial", "description": "Registre uma leitura de pressão arterial e o gráfico de tendência, a parcela dentro da faixa e a classificação da OMS aparecerão aqui.", - "cta": "Registrar pressão arterial" + "cta": "Registrar pressão arterial", + "coachPrefill": "Ainda não registrei a pressão arterial. Por que ela importa, e o que devo saber antes de começar?" }, "weight": { "title": "Ainda não há registros de peso", "description": "Registre um peso e a tendência, a média móvel e a faixa-alvo aparecerão automaticamente.", - "cta": "Registrar peso" + "cta": "Registrar peso", + "coachPrefill": "Ainda não registrei meu peso. Por que ele importa, e o que devo saber antes de começar a acompanhá-lo?" }, "pulse": { "title": "Ainda não há registros de pulso", "description": "Registre uma leitura de pulso e o pulso em repouso, a VFC e o VO₂ máx aparecerão nesta visão.", - "cta": "Registrar pulso" + "cta": "Registrar pulso", + "coachPrefill": "Ainda não registrei o pulso em repouso. Por que ele importa, e o que devo saber antes de começar?" }, "bmi": { "title": "IMC ainda não calculado", "description": "Assim que a altura e pelo menos um registro de peso estiverem presentes, a tendência do IMC com as zonas da OMS aparecerá aqui.", - "cta": "Registrar peso" + "cta": "Registrar peso", + "coachPrefill": "Ainda não registrei meu peso. Por que o IMC importa para mim, e o que devo saber antes de começar a acompanhá-lo?" }, "mood": { "title": "Ainda não há registros de humor", "description": "Registre uma entrada de humor e a tendência, a faixa de estabilidade e as correlações aparecerão.", - "cta": "Registrar humor" + "cta": "Registrar humor", + "coachPrefill": "Ainda não registrei meu humor. Por que acompanhar o humor importa, e como devo começar?" }, "medication": { "title": "Ainda não há medicamentos configurados", @@ -3788,7 +3799,8 @@ "sleep": { "title": "Ainda não há registros de sono", "description": "Assim que os dados de sono chegarem (manual ou via Withings / Apple Health), a divisão por estágio por noite aparecerá aqui.", - "cta": "Conectar uma fonte" + "cta": "Conectar uma fonte", + "coachPrefill": "Ainda não tenho dados de sono. Por que acompanhar o sono importa, e o que devo saber antes de conectar uma fonte?" } }, "navWorkouts": "Treinos", @@ -3798,7 +3810,8 @@ "description": "Corridas, pedaladas, caminhadas e sessões de força recentes, deduplicadas entre as fontes Apple Watch e Withings.", "emptyState": { "title": "Ainda não há treinos", - "description": "Os treinos sincronizam do Apple Health no seu iPhone e da Withings. Você também pode registrar um manualmente." + "description": "Os treinos sincronizam do Apple Health no seu iPhone e da Withings. Você também pode registrar um manualmente.", + "coachPrefill": "Ainda não registrei treinos. Por que acompanhá-los importa, e no que devo focar primeiro?" }, "detail": { "backToList": "Voltar aos treinos", @@ -4678,7 +4691,8 @@ "emptyState": { "title": "Ainda não há dados de quedas", "description": "As quedas detectadas aparecem aqui assim que o Apple Health sincroniza. Nenhuma queda é a meta." - } + }, + "coachPrefill": "Ainda não registrei dados de quedas. O que essa métrica me diz sobre minha saúde, e como reduzo meu risco de queda?" }, "navFalls": "Quedas", "sixMinuteWalk": { diff --git a/messages/ru.json b/messages/ru.json index c96b8a0b2..1b46bcf5b 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -3516,7 +3516,8 @@ "emptyState": { "title": "Пока нет данных HRV", "description": "Данные HRV появятся здесь, как только Apple Watch (или совместимое устройство Withings) начнёт синхронизировать ночные измерения." - } + }, + "coachPrefill": "Я ещё не записывал данные HRV. Что говорит вариабельность сердечного ритма и как её измерять?" }, "restingHr": { "title": "ЧСС в покое", @@ -3535,7 +3536,8 @@ "emptyState": { "title": "Пока нет данных SpO₂", "description": "Данные SpO₂ появятся здесь, как только совместимое носимое устройство (Apple Watch, Withings ScanWatch) сделает измерение." - } + }, + "coachPrefill": "Я ещё не записывал насыщение кислородом. Что говорит SpO₂ и к какому диапазону стоит стремиться?" }, "bodyTemperature": { "title": "Температура тела", @@ -3545,7 +3547,8 @@ "title": "Пока нет температуры тела", "description": "Запишите температуру тела или дайте носимо­му устройству синхронизировать её, и динамика появится здесь.", "cta": "Записать температуру тела" - } + }, + "coachPrefill": "Я ещё не записывал температуру тела. Какой диапазон считается нормальным и что делать, если она отклоняется?" }, "steps": { "title": "Шаги", @@ -3554,7 +3557,8 @@ "emptyState": { "title": "Пока нет данных о шагах", "description": "Ежедневные итоги шагов появятся здесь, как только Apple Health или устройство активности синхронизирует день движения." - } + }, + "coachPrefill": "Я ещё не записывал шаги. Какая дневная цель по шагам разумна и как больше ходьбы помогает здоровью?" }, "activeEnergy": { "title": "Активная энергия", @@ -3563,7 +3567,8 @@ "emptyState": { "title": "Пока нет данных об активной энергии", "description": "Итоги активной энергии появятся здесь, как только Apple Health или устройство активности Withings синхронизирует день движения." - } + }, + "coachPrefill": "Я ещё не записывал активную энергию. Какая дневная цель разумна и как она соотносится с общим расходом калорий?" }, "subPage": { "loadError": "Не удалось загрузить этот показатель прямо сейчас.", @@ -3658,7 +3663,8 @@ "valuesBack": "Назад к аналитике", "valuesBackToMetric": "Назад к {metric}", "scoresBack": "Назад к аналитике", - "ecgDescription": "Записи ЭКГ с одним отведением с вашего устройства, каждая с собственным результатом устройства." + "ecgDescription": "Записи ЭКГ с одним отведением с вашего устройства, каждая с собственным результатом устройства.", + "emptyCoachPrefill": "{metric}: я ещё ничего здесь не записывал. Что этот показатель говорит о моём здоровье и как его улучшить?" }, "units": { "steps": "шаги", @@ -3758,27 +3764,32 @@ "bloodPressure": { "title": "Пока нет записей АД", "description": "Запишите измерение АД, и здесь появятся график динамики, доля значений в диапазоне и классификация ВОЗ.", - "cta": "Записать АД" + "cta": "Записать АД", + "coachPrefill": "Я ещё не записывал давление. Почему оно важно и что нужно знать, прежде чем начать?" }, "weight": { "title": "Пока нет записей веса", "description": "Запишите вес, и динамика, скользящее среднее и целевой диапазон появятся автоматически.", - "cta": "Записать вес" + "cta": "Записать вес", + "coachPrefill": "Я ещё не записывал вес. Почему он важен и что нужно знать, прежде чем начать его отслеживать?" }, "pulse": { "title": "Пока нет записей пульса", "description": "Запишите измерение пульса, и в этом виде появятся пульс в покое, HRV и VO₂ max.", - "cta": "Записать пульс" + "cta": "Записать пульс", + "coachPrefill": "Я ещё не записывал пульс в покое. Почему он важен и что нужно знать, прежде чем начать?" }, "bmi": { "title": "ИМТ ещё не рассчитан", "description": "Как только появятся рост и хотя бы одна запись веса, здесь появится динамика ИМТ с зонами ВОЗ.", - "cta": "Записать вес" + "cta": "Записать вес", + "coachPrefill": "Я ещё не записывал вес. Почему ИМТ важен для меня и что нужно знать, прежде чем начать его отслеживать?" }, "mood": { "title": "Пока нет записей настроения", "description": "Запишите настроение, и появятся динамика, диапазон стабильности и корреляции.", - "cta": "Записать настроение" + "cta": "Записать настроение", + "coachPrefill": "Я ещё не записывал настроение. Почему важно его отслеживать и с чего начать?" }, "medication": { "title": "Пока не настроено ни одного лекарства", @@ -3788,7 +3799,8 @@ "sleep": { "title": "Пока нет записей сна", "description": "Как только появятся данные о сне (вручную или через Withings / Apple Health), здесь появится разбивка по фазам за каждую ночь.", - "cta": "Подключить источник" + "cta": "Подключить источник", + "coachPrefill": "У меня ещё нет данных о сне. Почему важно отслеживать сон и что нужно знать, прежде чем подключить источник?" } }, "navWorkouts": "Тренировки", @@ -3798,7 +3810,8 @@ "description": "Недавние пробежки, поездки на велосипеде, прогулки и силовые занятия, без дублей между источниками Apple Watch и Withings.", "emptyState": { "title": "Пока нет тренировок", - "description": "Тренировки синхронизируются из Apple Health на вашем iPhone и из Withings. Вы также можете записать одну вручную." + "description": "Тренировки синхронизируются из Apple Health на вашем iPhone и из Withings. Вы также можете записать одну вручную.", + "coachPrefill": "Я ещё не записывал тренировки. Почему важно их отслеживать и на чём сосредоточиться в первую очередь?" }, "detail": { "backToList": "Назад к тренировкам", @@ -4678,7 +4691,8 @@ "emptyState": { "title": "Пока нет данных о падениях", "description": "Обнаруженные падения появятся здесь, когда Apple Health синхронизирует их. Цель — ноль падений." - } + }, + "coachPrefill": "Я ещё не записывал данные о падениях. Что этот показатель говорит о моём здоровье и как снизить риск падений?" }, "navFalls": "Падения", "sixMinuteWalk": { diff --git a/src/app/insights/active-energy/page.tsx b/src/app/insights/active-energy/page.tsx index 2e0768bb4..c37b5c1c6 100644 --- a/src/app/insights/active-energy/page.tsx +++ b/src/app/insights/active-energy/page.tsx @@ -3,6 +3,7 @@ import { Flame } from "lucide-react"; import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; +import { useTranslations } from "@/lib/i18n/context"; /** * v1.4.32 — `/insights/active-energy`. @@ -14,6 +15,7 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page * imports collapse cleanly via the v1.4.30 drain script. */ export default function InsightsActiveEnergyPage() { + const { t } = useTranslations(); return ( } emptyStateCtaType={null} - coachPrefill="I haven't logged any active energy yet. What's a reasonable daily target, and how does it compare to total calories?" + coachPrefill={t("insights.activeEnergy.coachPrefill")} /> ); } diff --git a/src/app/insights/audio-events/page.tsx b/src/app/insights/audio-events/page.tsx index c77ca3c68..feb1415bf 100644 --- a/src/app/insights/audio-events/page.tsx +++ b/src/app/insights/audio-events/page.tsx @@ -26,7 +26,6 @@ export default function InsightsLaermereignissePage() { yAxisUnit="" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any loud-environment events yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/blood-glucose/page.tsx b/src/app/insights/blood-glucose/page.tsx index 60508761c..6e3255739 100644 --- a/src/app/insights/blood-glucose/page.tsx +++ b/src/app/insights/blood-glucose/page.tsx @@ -60,7 +60,6 @@ export default function InsightsBlutzuckerPage() { captureType="BLOOD_GLUCOSE" targetSummarySlug="blood-glucose" afterChart={} - coachPrefill="I haven't logged any blood glucose yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index 6cfcd1a45..782110ac7 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -87,7 +87,7 @@ export default function InsightsBlutdruckPage() { } - coachPrefill="I haven't recorded any blood pressure yet. Why does it matter, and what should I know before I start?" + coachPrefill={t("insights.emptyState.bloodPressure.coachPrefill")} /> ); diff --git a/src/app/insights/bmi/page.tsx b/src/app/insights/bmi/page.tsx index cc6db7390..30d5b1ce1 100644 --- a/src/app/insights/bmi/page.tsx +++ b/src/app/insights/bmi/page.tsx @@ -90,7 +90,7 @@ export default function InsightsBmiPage() { } - coachPrefill="I haven't recorded any weight yet. Why does BMI matter for me, and what should I know before I start tracking it?" + coachPrefill={t("insights.emptyState.bmi.coachPrefill")} /> ); diff --git a/src/app/insights/body-temperature/page.tsx b/src/app/insights/body-temperature/page.tsx index 9c2ed241d..8ce4c7c02 100644 --- a/src/app/insights/body-temperature/page.tsx +++ b/src/app/insights/body-temperature/page.tsx @@ -3,6 +3,7 @@ import { Thermometer } from "lucide-react"; import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; +import { useTranslations } from "@/lib/i18n/context"; /** * v1.4.32 — `/insights/body-temperature`. @@ -13,6 +14,7 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page * them on separate enum values per the v1.4.30 categorisation overlay. */ export default function InsightsBodyTemperaturePage() { + const { t } = useTranslations(); return ( } emptyStateCtaType="BODY_TEMPERATURE" - coachPrefill="I haven't logged any body temperature yet. What's the healthy range, and what should I do if it drifts?" + coachPrefill={t("insights.bodyTemperature.coachPrefill")} /> ); } diff --git a/src/app/insights/body-water/page.tsx b/src/app/insights/body-water/page.tsx index e5483e26c..e0954cc50 100644 --- a/src/app/insights/body-water/page.tsx +++ b/src/app/insights/body-water/page.tsx @@ -25,7 +25,6 @@ export default function InsightsKoerperwasserPage() { emptyStateIcon={} emptyStateCtaType={null} captureType="TOTAL_BODY_WATER" - coachPrefill="I haven't logged any total body water yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/bone-mass/page.tsx b/src/app/insights/bone-mass/page.tsx index acecafe5e..daf73c6fd 100644 --- a/src/app/insights/bone-mass/page.tsx +++ b/src/app/insights/bone-mass/page.tsx @@ -25,7 +25,6 @@ export default function InsightsKnochenmassePage() { emptyStateIcon={} emptyStateCtaType={null} captureType="BONE_MASS" - coachPrefill="I haven't logged any bone mass yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/breathing-disturbances/page.tsx b/src/app/insights/breathing-disturbances/page.tsx index 52d092204..76490022f 100644 --- a/src/app/insights/breathing-disturbances/page.tsx +++ b/src/app/insights/breathing-disturbances/page.tsx @@ -24,7 +24,6 @@ export default function InsightsBreathingDisturbancesPage() { unitKey="insights.units.breathingEvents" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any sleep-breathing-disturbance data yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/cardio-fitness/page.tsx b/src/app/insights/cardio-fitness/page.tsx index 8ea2756fc..582dd00d0 100644 --- a/src/app/insights/cardio-fitness/page.tsx +++ b/src/app/insights/cardio-fitness/page.tsx @@ -27,7 +27,6 @@ export default function InsightsCardioFitnessPage() { statIcon={Gauge} emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any cardio-fitness (VO₂ max) data yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/cardio-recovery/page.tsx b/src/app/insights/cardio-recovery/page.tsx index f54fdf5e1..347be3257 100644 --- a/src/app/insights/cardio-recovery/page.tsx +++ b/src/app/insights/cardio-recovery/page.tsx @@ -26,7 +26,6 @@ export default function InsightsCardioRecoveryPage() { yAxisUnit="bpm" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any cardio-recovery data yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/daylight/page.tsx b/src/app/insights/daylight/page.tsx index f6b4d023f..3760a54c1 100644 --- a/src/app/insights/daylight/page.tsx +++ b/src/app/insights/daylight/page.tsx @@ -26,7 +26,6 @@ export default function InsightsTageslichtPage() { yAxisUnit="min" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any time spent in daylight yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/double-support-time/page.tsx b/src/app/insights/double-support-time/page.tsx index 021ccb94a..5f767ac5d 100644 --- a/src/app/insights/double-support-time/page.tsx +++ b/src/app/insights/double-support-time/page.tsx @@ -26,7 +26,6 @@ export default function InsightsDoppelstandphasePage() { yAxisUnit="%" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any double support time yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/environmental-audio/page.tsx b/src/app/insights/environmental-audio/page.tsx index 42e9e41d9..0f82ac5f0 100644 --- a/src/app/insights/environmental-audio/page.tsx +++ b/src/app/insights/environmental-audio/page.tsx @@ -26,7 +26,6 @@ export default function InsightsLaermbelastungPage() { yAxisUnit="dBA" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any environmental sound exposure yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/falls/page.tsx b/src/app/insights/falls/page.tsx index 6144b740a..093c55178 100644 --- a/src/app/insights/falls/page.tsx +++ b/src/app/insights/falls/page.tsx @@ -3,6 +3,7 @@ import { PersonStanding } from "lucide-react"; import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; +import { useTranslations } from "@/lib/i18n/context"; /** * v1.10.0 — `/insights/falls`. @@ -11,6 +12,7 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page * better. The series arrives from Apple Health sync — no manual-entry CTA. */ export default function InsightsFallsPage() { + const { t } = useTranslations(); return ( } emptyStateCtaType={null} - coachPrefill="I haven't logged any fall data yet. What does this metric tell me about my health, and how do I lower my fall risk?" + coachPrefill={t("insights.falls.coachPrefill")} /> ); } diff --git a/src/app/insights/fat-free-mass/page.tsx b/src/app/insights/fat-free-mass/page.tsx index c51c7f995..d41e85dc6 100644 --- a/src/app/insights/fat-free-mass/page.tsx +++ b/src/app/insights/fat-free-mass/page.tsx @@ -24,7 +24,6 @@ export default function InsightsFettfreieMassePage() { color="var(--chart-1)" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any fat-free mass yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/fat-mass/page.tsx b/src/app/insights/fat-mass/page.tsx index 835faa73d..832463239 100644 --- a/src/app/insights/fat-mass/page.tsx +++ b/src/app/insights/fat-mass/page.tsx @@ -24,7 +24,6 @@ export default function InsightsFettmassePage() { color="var(--chart-5)" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any fat mass yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/flights-climbed/page.tsx b/src/app/insights/flights-climbed/page.tsx index 9aa247148..5aaa8399f 100644 --- a/src/app/insights/flights-climbed/page.tsx +++ b/src/app/insights/flights-climbed/page.tsx @@ -25,7 +25,6 @@ export default function InsightsStockwerkePage() { unitKey="insights.units.flights" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any flights climbed yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/headphone-audio/page.tsx b/src/app/insights/headphone-audio/page.tsx index fafcaabbb..169d5b965 100644 --- a/src/app/insights/headphone-audio/page.tsx +++ b/src/app/insights/headphone-audio/page.tsx @@ -26,7 +26,6 @@ export default function InsightsKopfhoererpegelPage() { yAxisUnit="dBA" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any headphone audio exposure yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/hrv/page.tsx b/src/app/insights/hrv/page.tsx index 06f3462a4..fb4ff3cca 100644 --- a/src/app/insights/hrv/page.tsx +++ b/src/app/insights/hrv/page.tsx @@ -3,6 +3,7 @@ import { Activity } from "lucide-react"; import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; +import { useTranslations } from "@/lib/i18n/context"; import { EcgCrossLink } from "@/components/insights/ecg-cross-link"; /** @@ -20,6 +21,7 @@ import { EcgCrossLink } from "@/components/insights/ecg-cross-link"; * own, labelled, because the two are not one measure. */ export default function InsightsHrvPage() { + const { t } = useTranslations(); return ( } emptyStateCtaType={null} - coachPrefill="I haven't logged any HRV data yet. What does heart-rate variability tell me, and how do I capture it?" + coachPrefill={t("insights.hrv.coachPrefill")} // S10 / H1 — device-attributed pointer into the ECG viewer, in the // HRV / heart context. Self-gates to nothing without recordings. afterAssessment={} diff --git a/src/app/insights/lean-body-mass/page.tsx b/src/app/insights/lean-body-mass/page.tsx index ac41b155f..4d9453b83 100644 --- a/src/app/insights/lean-body-mass/page.tsx +++ b/src/app/insights/lean-body-mass/page.tsx @@ -24,7 +24,6 @@ export default function InsightsMagermassePage() { color="var(--chart-1)" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any lean body mass yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/mood/page-client.tsx b/src/app/insights/mood/page-client.tsx index b543a4c08..00c75e12d 100644 --- a/src/app/insights/mood/page-client.tsx +++ b/src/app/insights/mood/page-client.tsx @@ -132,7 +132,7 @@ export default function InsightsMoodPageClient() { {t("insights.emptyState.mood.cta")} } - coachPrefill="I haven't logged any mood entries yet. Why does mood tracking matter, and how should I start?" + coachPrefill={t("insights.emptyState.mood.coachPrefill")} /> ); diff --git a/src/app/insights/muscle-mass/page.tsx b/src/app/insights/muscle-mass/page.tsx index 42ce5fa8f..4538d0bb9 100644 --- a/src/app/insights/muscle-mass/page.tsx +++ b/src/app/insights/muscle-mass/page.tsx @@ -24,7 +24,6 @@ export default function InsightsMuskelmassePage() { color="var(--chart-1)" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any muscle mass yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/oxygen/__tests__/page.test.tsx b/src/app/insights/oxygen/__tests__/page.test.tsx index ca7a6b1aa..9580f2f86 100644 --- a/src/app/insights/oxygen/__tests__/page.test.tsx +++ b/src/app/insights/oxygen/__tests__/page.test.tsx @@ -13,6 +13,7 @@ import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { I18nProvider } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import type { ValueBand } from "@/lib/analytics/value-bands"; @@ -37,7 +38,9 @@ function bandsRendered(thresholds: unknown): ValueBand[] { } const html = renderToStaticMarkup( - + + + , ); const match = /]*>([^<]*)<\/pre>/.exec(html); diff --git a/src/app/insights/oxygen/page.tsx b/src/app/insights/oxygen/page.tsx index f45c47a81..9281fadec 100644 --- a/src/app/insights/oxygen/page.tsx +++ b/src/app/insights/oxygen/page.tsx @@ -3,6 +3,7 @@ import { Wind } from "lucide-react"; import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; +import { useTranslations } from "@/lib/i18n/context"; import { buildOxygenSaturationBands } from "@/lib/analytics/value-bands"; import { useOwnTargetRange } from "@/lib/queries/use-own-target-range"; @@ -20,6 +21,7 @@ import { useOwnTargetRange } from "@/lib/queries/use-own-target-range"; * `/targets`, and the clinical bands otherwise. */ export default function InsightsOxygenSaturationPage() { + const { t } = useTranslations(); const ownRange = useOwnTargetRange("OXYGEN_SATURATION"); return ( } emptyStateCtaType={null} captureType="OXYGEN_SATURATION" - coachPrefill="I haven't logged any oxygen-saturation data yet. What does SpO₂ tell me, and what range should I aim for?" + coachPrefill={t("insights.oxygenSaturation.coachPrefill")} /> ); } diff --git a/src/app/insights/pulse-wave-velocity/page.tsx b/src/app/insights/pulse-wave-velocity/page.tsx index ae16b14b4..069723f38 100644 --- a/src/app/insights/pulse-wave-velocity/page.tsx +++ b/src/app/insights/pulse-wave-velocity/page.tsx @@ -26,7 +26,6 @@ export default function InsightsPulswellengeschwindigkeitPage() { yAxisUnit="m/s" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any pulse wave velocity yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index 9f41dbe02..49c1d2357 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -115,7 +115,7 @@ export default function InsightsPulsPage() { } - coachPrefill="I haven't recorded any resting pulse yet. Why does it matter, and what should I know before I start?" + coachPrefill={t("insights.emptyState.pulse.coachPrefill")} /> ); diff --git a/src/app/insights/respiratory-rate/page.tsx b/src/app/insights/respiratory-rate/page.tsx index ac58a3568..6b4dfd20f 100644 --- a/src/app/insights/respiratory-rate/page.tsx +++ b/src/app/insights/respiratory-rate/page.tsx @@ -25,7 +25,6 @@ export default function InsightsAtemfrequenzPage() { unitKey="insights.units.respiratoryRate" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any respiratory rate yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/six-minute-walk/page.tsx b/src/app/insights/six-minute-walk/page.tsx index 3bc1905f6..e75ea0013 100644 --- a/src/app/insights/six-minute-walk/page.tsx +++ b/src/app/insights/six-minute-walk/page.tsx @@ -25,7 +25,6 @@ export default function InsightsSixMinuteWalkPage() { yAxisUnit="m" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any six-minute-walk data yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/skin-temperature/page.tsx b/src/app/insights/skin-temperature/page.tsx index 1fff4f129..ca810282e 100644 --- a/src/app/insights/skin-temperature/page.tsx +++ b/src/app/insights/skin-temperature/page.tsx @@ -32,7 +32,6 @@ export default function InsightsHauttemperaturPage() { color="var(--chart-5)" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any skin temperature yet. What does this metric tell me about my health, and how do I improve it?" afterChart={ } - coachPrefill="I don't have any sleep data yet. Why does sleep tracking matter, and what should I know before I connect a source?" + coachPrefill={t("insights.emptyState.sleep.coachPrefill")} /> ); diff --git a/src/app/insights/stair-ascent-speed/page.tsx b/src/app/insights/stair-ascent-speed/page.tsx index 0d4e85b3b..c712ce997 100644 --- a/src/app/insights/stair-ascent-speed/page.tsx +++ b/src/app/insights/stair-ascent-speed/page.tsx @@ -25,7 +25,6 @@ export default function InsightsStairAscentSpeedPage() { yAxisUnit="m/s" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any stair-ascent-speed data yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/stair-descent-speed/page.tsx b/src/app/insights/stair-descent-speed/page.tsx index 948a67404..d4bbe2ed4 100644 --- a/src/app/insights/stair-descent-speed/page.tsx +++ b/src/app/insights/stair-descent-speed/page.tsx @@ -25,7 +25,6 @@ export default function InsightsStairDescentSpeedPage() { yAxisUnit="m/s" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any stair-descent-speed data yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/step-length/page.tsx b/src/app/insights/step-length/page.tsx index 2eed765ca..474b932de 100644 --- a/src/app/insights/step-length/page.tsx +++ b/src/app/insights/step-length/page.tsx @@ -26,7 +26,6 @@ export default function InsightsSchrittlaengePage() { yAxisUnit="m" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any walking step length yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/steps/page.tsx b/src/app/insights/steps/page.tsx index a562c4baf..ef07e757d 100644 --- a/src/app/insights/steps/page.tsx +++ b/src/app/insights/steps/page.tsx @@ -40,7 +40,7 @@ export default function InsightsStepsPage() { emptyStateIcon={} emptyStateCtaType={null} captureType="ACTIVITY_STEPS" - coachPrefill="I haven't logged any steps yet. What's a reasonable daily step target, and how does walking more help my health?" + coachPrefill={t("insights.steps.coachPrefill")} afterChart={ calendarVisible ? ( } emptyStateCtaType={null} - coachPrefill="I haven't logged any vascular age yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/visceral-fat/page.tsx b/src/app/insights/visceral-fat/page.tsx index ae22f62b8..15358bb02 100644 --- a/src/app/insights/visceral-fat/page.tsx +++ b/src/app/insights/visceral-fat/page.tsx @@ -26,7 +26,6 @@ export default function InsightsViszeralfettPage() { yAxisUnit="" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any visceral fat yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/walking-asymmetry/page.tsx b/src/app/insights/walking-asymmetry/page.tsx index 640f05f06..26f04a998 100644 --- a/src/app/insights/walking-asymmetry/page.tsx +++ b/src/app/insights/walking-asymmetry/page.tsx @@ -26,7 +26,6 @@ export default function InsightsGangasymmetriePage() { yAxisUnit="%" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any walking asymmetry yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/walking-distance/page.tsx b/src/app/insights/walking-distance/page.tsx index 5d4c4cf4b..bc8c1a334 100644 --- a/src/app/insights/walking-distance/page.tsx +++ b/src/app/insights/walking-distance/page.tsx @@ -24,7 +24,6 @@ export default function InsightsGehstreckePage() { color="var(--chart-2)" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any walking and running distance yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/walking-heart-rate/page.tsx b/src/app/insights/walking-heart-rate/page.tsx index ae4ba79d6..5d1b7aba1 100644 --- a/src/app/insights/walking-heart-rate/page.tsx +++ b/src/app/insights/walking-heart-rate/page.tsx @@ -26,7 +26,6 @@ export default function InsightsGehpulsPage() { yAxisUnit="bpm" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any walking heart rate average yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/walking-speed/page.tsx b/src/app/insights/walking-speed/page.tsx index fc4b5d809..39128371e 100644 --- a/src/app/insights/walking-speed/page.tsx +++ b/src/app/insights/walking-speed/page.tsx @@ -24,7 +24,6 @@ export default function InsightsGehgeschwindigkeitPage() { color="var(--chart-2)" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any walking speed yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/walking-steadiness/page.tsx b/src/app/insights/walking-steadiness/page.tsx index 0fa566184..33649e17a 100644 --- a/src/app/insights/walking-steadiness/page.tsx +++ b/src/app/insights/walking-steadiness/page.tsx @@ -26,7 +26,6 @@ export default function InsightsGangstabilitaetPage() { yAxisUnit="%" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any walking steadiness yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index 75101098f..2023ee9ed 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -122,7 +122,7 @@ export default function InsightsGewichtPage() { } - coachPrefill="I haven't recorded any weight yet. Why does it matter, and what should I know before I start tracking?" + coachPrefill={t("insights.emptyState.weight.coachPrefill")} /> ); diff --git a/src/app/insights/workouts/page-client.tsx b/src/app/insights/workouts/page-client.tsx index e9ea0f979..f92a8ae9b 100644 --- a/src/app/insights/workouts/page-client.tsx +++ b/src/app/insights/workouts/page-client.tsx @@ -142,7 +142,7 @@ export default function InsightsWorkoutsPageClient() { ) : null } - coachPrefill="I haven't logged any workouts yet. Why does tracking them matter, and what should I focus on first?" + coachPrefill={t("insights.workouts.emptyState.coachPrefill")} /> ) : ( <> diff --git a/src/app/insights/wrist-temperature/page.tsx b/src/app/insights/wrist-temperature/page.tsx index 44a005ff2..2f1a5f23a 100644 --- a/src/app/insights/wrist-temperature/page.tsx +++ b/src/app/insights/wrist-temperature/page.tsx @@ -24,7 +24,6 @@ export default function InsightsWristTemperaturePage() { color="var(--chart-5)" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any wrist-temperature data yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/components/insights/__tests__/healthkit-metric-page-coach-prefill.test.tsx b/src/components/insights/__tests__/healthkit-metric-page-coach-prefill.test.tsx new file mode 100644 index 000000000..99013aa5c --- /dev/null +++ b/src/components/insights/__tests__/healthkit-metric-page-coach-prefill.test.tsx @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { primeMessages } from "@/lib/i18n/load-locale"; +import deMessages from "../../../../messages/de.json"; + +primeMessages("de", deMessages); + +/** + * A metric page with no data offers the Coach a question in the reader's + * language. The pages used to pass their own English sentence, and most of + * them were one sentence with the metric's name swapped in; the page now asks + * that one, translated, with its own title, and a metric with a question of + * its own reads it from the catalog too. + */ + +const prefills: Array = []; +vi.mock("@/components/insights/metric-empty-state", () => ({ + MetricEmptyState: ({ coachPrefill }: { coachPrefill?: string | null }) => { + prefills.push(coachPrefill); + return null; + }, +})); +vi.mock("@/hooks/use-ai-capability", () => ({ + useAiCapability: () => ({ available: true, reason: null }), +})); +vi.mock("next/dynamic", () => ({ default: () => () => null })); +vi.mock("next/navigation", () => ({ + usePathname: () => "/insights/visceral-fat", +})); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { timezone: "UTC", dateOfBirth: null, gender: null }, + isAuthenticated: true, + }), +})); +vi.mock("@/hooks/use-insights-layout-prefs", () => ({ + useInsightsLayoutPrefs: () => ({ layout: null, compareBaseline: false }), +})); +vi.mock("@/hooks/use-insights-analytics", () => ({ + useInsightsAnalytics: () => ({ data: { summaries: {} }, isEmpty: true }), +})); + +import InsightsViszeralfettPage from "@/app/insights/visceral-fat/page"; +import InsightsStepsPage from "@/app/insights/steps/page"; + +function emptyStatePrefill(node: React.ReactNode, locale: "en" | "de") { + prefills.length = 0; + renderToStaticMarkup( + + {node} + , + ); + return prefills.at(-1); +} + +describe("the empty metric page's Coach question", () => { + it("is the shared sentence with the page's title, in the reader's language", () => { + expect(emptyStatePrefill(, "de")).toBe( + "Viszeralfett: Ich habe hier noch nichts erfasst. Was sagt dieser Wert über meine Gesundheit aus, und wie verbessere ich ihn?", + ); + expect(emptyStatePrefill(, "en")).toBe( + "Visceral Fat: I haven't logged anything here yet. What does this metric tell me about my health, and how do I improve it?", + ); + }); + + it("keeps a metric's own question, translated", () => { + expect(emptyStatePrefill(, "de")).toBe( + "Ich habe noch keine Schritte erfasst. Was ist ein sinnvolles tägliches Schrittziel, und wie hilft mehr Gehen meiner Gesundheit?", + ); + }); +}); diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index a59a2baa3..4a2cdb59d 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -148,9 +148,8 @@ export interface HealthKitMetricPageProps { */ statIcon?: ComponentType<{ className?: string }>; /** - * Optional Coach prefill for the empty-state launch. Falls back to a - * generic onboarding prompt threaded through ``'s - * default. + * The empty state's Coach question, for a metric that asks its own. Without + * it the page asks the shared, translated one with its own title. */ coachPrefill?: string; /** @@ -223,6 +222,17 @@ export interface HealthKitMetricPageProps { afterAssessment?: ReactNode; } +/** + * The empty state's Coach question for a metric that asks none of its own: + * one translated sentence around the page's title. + */ +function sharedCoachPrefill( + t: ReturnType["t"], + metric: string, +): string { + return t("insights.subPage.emptyCoachPrefill", { metric }); +} + export function HealthKitMetricPage({ measurementType, fallbackMeasurementType, @@ -424,7 +434,7 @@ export function HealthKitMetricPage({ title={t(`${i18nPrefix}.emptyState.title`)} description={t(`${i18nPrefix}.emptyState.description`)} cta={ctaNode} - coachPrefill={coachPrefill ?? null} + coachPrefill={coachPrefill ?? sharedCoachPrefill(t, title)} /> ); From 848e88d58ebfea4b7d919c8bedbd4c7f23cce337 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 22:11:37 +0100 Subject: [PATCH 11/13] fix(admin): hold the recovery readiness list's height while it loads `/admin/backups` opens with the recovery readiness card, which showed a one-line spinner until its seven signals arrived as one block, so the backups and off-host cards below jumped down by the whole list after they had drawn. The loading state now draws one placeholder row per signal in the list's own row shape, with the loading text kept for screen readers. --- .../recovery-readiness-card.test.tsx | 22 ++++++++++- .../admin/recovery-readiness-card.tsx | 38 +++++++++++++++---- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/components/admin/__tests__/recovery-readiness-card.test.tsx b/src/components/admin/__tests__/recovery-readiness-card.test.tsx index 3d130ed3b..ac09f58fa 100644 --- a/src/components/admin/__tests__/recovery-readiness-card.test.tsx +++ b/src/components/admin/__tests__/recovery-readiness-card.test.tsx @@ -24,7 +24,10 @@ import type { ReadinessSignal, RecoveryReadiness, } from "@/components/admin/use-recovery-readiness"; -import { READINESS_FIX_PATHS } from "@/lib/recovery/readiness-model"; +import { + READINESS_FIX_PATHS, + READINESS_SIGNALS, +} from "@/lib/recovery/readiness-model"; import { primeLocales } from "@/__tests__/helpers/prime-locales"; primeLocales("de"); @@ -97,6 +100,23 @@ function row(html: string, id: string): string { } describe("RecoveryReadinessCard", () => { + it("holds one row per signal while it loads, so the cards below stay put", () => { + // A read that has not answered: no data, no error. + const client = new QueryClient(); + const html = renderToStaticMarkup( + + + + + , + ); + const start = html.indexOf('data-testid="recovery-readiness-loading"'); + expect(start).toBeGreaterThan(-1); + expect(html.slice(start).match(/
  • { pathname.current = "/admin"; const html = render(READINESS); diff --git a/src/components/admin/recovery-readiness-card.tsx b/src/components/admin/recovery-readiness-card.tsx index d77ae64d1..34189912b 100644 --- a/src/components/admin/recovery-readiness-card.tsx +++ b/src/components/admin/recovery-readiness-card.tsx @@ -24,7 +24,6 @@ import { CheckCircle2, Clock, LifeBuoy, - Loader2, MinusCircle, XCircle, } from "lucide-react"; @@ -34,6 +33,7 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { Badge } from "@/components/ui/badge"; import { InlineAction } from "@/components/ui/inline-action"; import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { Skeleton } from "@/components/ui/skeleton"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; @@ -44,6 +44,17 @@ import { type RecoveryReadiness, } from "./use-recovery-readiness"; +/** One placeholder row per readiness signal the card always lists. */ +const READINESS_SIGNAL_SLOTS = [ + "database_dump", + "weekly_copy", + "offhost_copies", + "restore_drill", + "offhost_setup", + "dump_retention", + "recovery_kit", +] as const satisfies readonly ReadinessSignal["id"][]; + const STATE_ICON: Record< ReadinessState, React.ComponentType<{ className?: string; "aria-hidden"?: boolean }> @@ -194,14 +205,27 @@ export function RecoveryReadinessCard() { onRetry={() => void refetch()} /> ) : ( -
    -