diff --git a/messages/de.json b/messages/de.json
index be3cf59b6..c690c5de5 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -2324,6 +2324,33 @@
"groupYesterday": "Gestern",
"groupThisWeek": "Diese Woche",
"groupEarlier": "Früher"
+ },
+ "seed": {
+ "bloodPressure": "Geh mit mir meinen Blutdruckverlauf der letzten 30 Tage durch — gibt es etwas, das ich im Auge behalten sollte?",
+ "weight": "Wie hat sich mein Gewicht zuletzt entwickelt, und ist die Richtung etwas, worauf ich reagieren sollte?",
+ "bmi": "Was sagt mir mein BMI-Verlauf, und wie sollte ich ihn lesen?",
+ "pulse": "Geh mit mir meine Pulswerte durch — ist irgendetwas ungewöhnlich?",
+ "restingHr": "Wie hat sich mein Ruhepuls entwickelt, und was sagt er über meine Fitness?",
+ "hrv": "Was sagt mir meine Herzfrequenzvariabilität zuletzt über Erholung und Stress?",
+ "sleep": "Geh mit mir meinen letzten Schlaf durch — Dauer, Regelmäßigkeit und was sich zu ändern lohnt.",
+ "mood": "Welche Muster siehst du in meiner Stimmung über die letzten Wochen?",
+ "medications": "Wie sieht meine Medikamentenadhärenz aus, und passt sie dazu, wie ich mich gefühlt habe?",
+ "steps": "Wie aktiv war ich zuletzt gemessen an meinen Schritten, und wie ist das im Vergleich zu einem gesunden Richtwert?",
+ "activeEnergy": "Was sagt mein Aktivenergie-Verlauf über mein Aktivitätsniveau zuletzt?",
+ "cardioFitness": "Was bedeutet mein Cardio-Fitness-Verlauf (VO₂max), und wie bewege ich ihn in die richtige Richtung?",
+ "bloodGlucose": "Geh mit mir meine letzten Glukosewerte durch — liegen sie in einem gesunden Bereich?",
+ "oxygenSaturation": "Was sagt mir mein Sauerstoffsättigungs-Verlauf (SpO₂)?",
+ "respiratoryRate": "Was sagt mein Atemfrequenz-Verlauf über meine Gesundheit?",
+ "workouts": "Geh mit mir meine letzten Workouts durch — Belastung, Häufigkeit und wie ich mich dazwischen erhole.",
+ "recoveryPage": "Warum steht meine Erholung gerade dort, wo sie steht, und was treibt das?",
+ "briefing": "Geh mit mir das heutige Briefing durch — worauf sollte ich mich konzentrieren, und gibt es etwas, das einen Schritt wert ist?",
+ "healthScore": "Warum liegt mein Gesundheits-Score bei {score} von 100, und was würde ihn am stärksten bewegen?",
+ "periodWeek": "Geh mit mir durch, wie meine letzte Woche über meine Gesundheitswerte hinweg lief — was sticht heraus?",
+ "periodMonth": "Geh mit mir durch, wie mein letzter Monat über meine Gesundheitswerte hinweg lief — was sticht heraus?",
+ "correlationBpCompliance": "Wie hängt meine Medikamentenadhärenz mit meinem Blutdruck zusammen? Geh mit mir durch, was dieser Zusammenhang für mich bedeutet.",
+ "correlationMoodPulse": "Gibt es einen echten Zusammenhang zwischen meiner Stimmung und meinem Puls? Hilf mir, diesen Zusammenhang zu lesen.",
+ "correlationWeightWeekday": "Folgt mein Gewicht einem Wochenmuster? Was sagt mir dieser Wochentags-Zusammenhang?",
+ "recommendation": "Erzähl mir mehr zu dieser Empfehlung: \"{text}\" — warum trifft sie auf mich zu, und was sollte ich tun?"
}
},
"relativeJustNow": "gerade eben",
@@ -3617,7 +3644,10 @@
"returnLabel": "Dein {metric} ist seit {count} Tagen wieder in deinem üblichen Bereich — was es auch war, es ist vorbei.",
"daysInside": "{count} Tage drinnen",
"daysOutside": "{count} Tage draußen"
- }
+ },
+ "updatedTodayAt": "Heute aktualisiert, {time}",
+ "updatedYesterday": "Gestern aktualisiert",
+ "updatedOn": "Aktualisiert am {date}"
},
"thresholds": {
"defaultLabel": "Default",
diff --git a/messages/en.json b/messages/en.json
index 0186d745a..9d9382cc6 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2324,6 +2324,33 @@
"groupYesterday": "Yesterday",
"groupThisWeek": "This week",
"groupEarlier": "Earlier"
+ },
+ "seed": {
+ "bloodPressure": "Walk me through my blood pressure trend over the last 30 days — anything I should keep an eye on?",
+ "weight": "How has my weight been trending lately, and is the direction something to act on?",
+ "bmi": "What does my BMI trend tell me, and how should I read it?",
+ "pulse": "Walk me through my pulse readings — is anything out of the ordinary?",
+ "restingHr": "How has my resting heart rate been trending, and what does it say about my fitness?",
+ "hrv": "What is my heart-rate variability telling me about recovery and stress lately?",
+ "sleep": "Walk me through my recent sleep — duration, consistency, and anything worth changing.",
+ "mood": "What patterns do you see in my mood over the last few weeks?",
+ "medications": "How is my medication adherence looking, and is it lined up with how I've been feeling?",
+ "steps": "How active have I been lately based on my steps, and how does it compare to a healthy baseline?",
+ "activeEnergy": "What does my active-energy trend say about my activity level lately?",
+ "cardioFitness": "What does my cardio fitness (VO₂ max) trend mean, and how do I move it in the right direction?",
+ "bloodGlucose": "Walk me through my recent glucose readings — are they in a healthy range?",
+ "oxygenSaturation": "What does my blood-oxygen (SpO₂) trend tell me?",
+ "respiratoryRate": "What does my respiratory-rate trend say about my health?",
+ "workouts": "Walk me through my recent workouts — load, frequency, and how I'm recovering between them.",
+ "recoveryPage": "Why is my recovery where it is right now, and what's driving it?",
+ "briefing": "Walk me through today's briefing — what should I focus on, and is anything worth acting on?",
+ "healthScore": "Why is my health score {score} out of 100, and what would move it the most?",
+ "periodWeek": "Walk me through how my last week went across my health metrics — what stands out?",
+ "periodMonth": "Walk me through how my last month went across my health metrics — what stands out?",
+ "correlationBpCompliance": "How does my medication adherence relate to my blood pressure? Walk me through what this correlation means for me.",
+ "correlationMoodPulse": "Is there a real link between my mood and my pulse? Help me read this correlation.",
+ "correlationWeightWeekday": "Does my weight follow a weekly pattern? What does this day-of-week correlation tell me?",
+ "recommendation": "Tell me more about this recommendation: \"{text}\" — why does it apply to me, and what should I do about it?"
}
},
"relativeJustNow": "just now",
@@ -3617,7 +3644,10 @@
"returnLabel": "Your {metric} has been back inside your usual range for {count} days now — whatever that was, it's passed.",
"daysInside": "{count} days inside",
"daysOutside": "{count} days outside"
- }
+ },
+ "updatedTodayAt": "Updated today, {time}",
+ "updatedYesterday": "Updated yesterday",
+ "updatedOn": "Updated on {date}"
},
"thresholds": {
"defaultLabel": "Default",
diff --git a/src/components/insights/__tests__/daily-briefing.test.tsx b/src/components/insights/__tests__/daily-briefing.test.tsx
index be94ffffb..10496b20b 100644
--- a/src/components/insights/__tests__/daily-briefing.test.tsx
+++ b/src/components/insights/__tests__/daily-briefing.test.tsx
@@ -119,13 +119,15 @@ describe("", () => {
expect(html).toContain("bg-info"); // tone=info bar
});
- it("renders the 'Generated
)}
{/* v1.18.9 (#4) — a stale briefing that can never refresh
@@ -504,7 +508,7 @@ export function DailyBriefing({
picture. The briefing spans every metric, so no scope: the
default all-source snapshot reads best here. */}
-
+
) : noProvider ? (
diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx
index 646ef15bc..7edc3ee1f 100644
--- a/src/components/insights/health-score-card.tsx
+++ b/src/components/insights/health-score-card.tsx
@@ -714,7 +714,7 @@ export function HealthScoreCard({
a single discreet entry, not a primary CTA. */}
diff --git a/src/components/insights/hero-strip.tsx b/src/components/insights/hero-strip.tsx
index 99a03f3f2..8ba087aba 100644
--- a/src/components/insights/hero-strip.tsx
+++ b/src/components/insights/hero-strip.tsx
@@ -6,6 +6,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { formatRelativeTime } from "@/lib/i18n/relative-time";
import { useModuleEnabled } from "@/hooks/use-module-enabled";
+import { ProseBlocks } from "@/components/insights/prose-blocks";
import { cn } from "@/lib/utils";
import {
HealthScoreCard,
@@ -291,12 +292,15 @@ export function HeroStrip({
{greeting}
-
- {subtitle}
-
+
+
{t("insights.heroPersonalBaseline")}
diff --git a/src/components/insights/insight-status-card.tsx b/src/components/insights/insight-status-card.tsx
index 9281606ba..1d6691376 100644
--- a/src/components/insights/insight-status-card.tsx
+++ b/src/components/insights/insight-status-card.tsx
@@ -6,9 +6,10 @@ import Link from "next/link";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { TileHeader } from "@/components/insights/tile-header";
-import { useTranslations } from "@/lib/i18n/context";
-import { formatRelativeTime } from "@/lib/i18n/relative-time";
+import { useTranslations, useFormatters } from "@/lib/i18n/context";
+import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
+import { ProseBlocks } from "@/components/insights/prose-blocks";
import { cn } from "@/lib/utils";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { AskCoachAction } from "@/components/insights/ask-coach-action";
@@ -273,17 +274,18 @@ function StatusBody({ text }: { text: string }) {
const { t } = useTranslations();
const [expanded, setExpanded] = useState(false);
const [overflows, setOverflows] = useState(false);
- const paragraphRef = useRef(null);
+ const wrapperRef = useRef(null);
- // Measure the clamped paragraph against its scroll height. When the
- // text fits inside the three-line clamp `scrollHeight` equals
- // `clientHeight`; an overflow means the clamp is hiding content and the
- // toggle earns its place. We measure against the *clamped* element, so
- // the check is gated on `!expanded` — once expanded the paragraph is no
- // longer clamped and the comparison would always read "fits".
+ // v1.22 (W6) — the assessment now renders through the shared `ProseBlocks`
+ // helper (real paragraphs). The 3-line clamp therefore moves from the single
+ // `
` to the BLOCK CONTAINER: a max-height + overflow-hidden when collapsed,
+ // measured on the wrapper node so multi-paragraph text doesn't re-collapse.
+ // When the text fits, `scrollHeight` equals `clientHeight`; an overflow means
+ // the clamp is hiding content and the toggle earns its place. Gated on
+ // `!expanded` so the comparison reads against the clamped element.
useEffect(() => {
if (expanded) return;
- const node = paragraphRef.current;
+ const node = wrapperRef.current;
if (!node) return;
const measure = () => {
@@ -301,15 +303,16 @@ function StatusBody({ text }: { text: string }) {
return (
-
- {text}
-
+
+
{/* Only render the toggle on a genuine overflow. When the full text
fits inside three lines there is nothing more to show, so no
affordance is painted. */}
@@ -335,6 +338,7 @@ function StatusBody({ text }: { text: string }) {
function LastUpdatedFooter({ updatedAt }: { updatedAt: string | null }) {
const { t } = useTranslations();
+ const fmt = useFormatters();
if (!updatedAt) return null;
return (
// v1.11.5 — right-aligned so the timestamp tucks to the trailing edge
@@ -345,7 +349,7 @@ function LastUpdatedFooter({ updatedAt }: { updatedAt: string | null }) {
// last-measurement / coach-history captions so two adjacent cards on the
// same page no longer read one relative and one absolute.
) : null}
diff --git a/src/components/insights/recommendation-card.tsx b/src/components/insights/recommendation-card.tsx
index 3ec2a930f..a8affcbd5 100644
--- a/src/components/insights/recommendation-card.tsx
+++ b/src/components/insights/recommendation-card.tsx
@@ -355,9 +355,9 @@ export function RecommendationCard({
// quotes the rec so the conversation lands on this exact suggestion;
// when the rec carries a metric source, scope the snapshot to it.
const coachScopeSource = scopeSourceFromMetricKey(norm.metricSource?.type);
- const coachQuestion = `Tell me more about this recommendation: "${stripChartTokens(
- norm.text,
- )}" — why does it apply to me, and what should I do about it?`;
+ const coachQuestion = t("insights.coach.seed.recommendation", {
+ text: stripChartTokens(norm.text),
+ });
return (
): string => {
+ if (key === "insights.updatedTodayAt")
+ return `Updated today, ${params?.time}`;
+ if (key === "insights.updatedYesterday") return "Updated yesterday";
+ if (key === "insights.updatedOn") return `Updated on ${params?.date}`;
+ return key;
+};
+const fmtDate = (d: Date) =>
+ `${String(d.getUTCDate()).padStart(2, "0")}.${String(d.getUTCMonth() + 1).padStart(2, "0")}.`;
+const fmtTime = (d: Date) =>
+ `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
+
+describe("formatUpdatedLabel", () => {
+ it("shows the time for today", () => {
+ const now = new Date();
+ const out = formatUpdatedLabel(
+ now.toISOString(),
+ t,
+ fmtDate,
+ fmtTime,
+ "UTC",
+ );
+ expect(out.startsWith("Updated today, ")).toBe(true);
+ });
+
+ it("omits the time for yesterday", () => {
+ const y = new Date();
+ y.setUTCDate(y.getUTCDate() - 1);
+ const out = formatUpdatedLabel(y.toISOString(), t, fmtDate, fmtTime, "UTC");
+ expect(out).toBe("Updated yesterday");
+ });
+
+ it("uses the date-only formatter for older timestamps", () => {
+ const out = formatUpdatedLabel(
+ "2020-01-15T08:00:00.000Z",
+ t,
+ fmtDate,
+ fmtTime,
+ "UTC",
+ );
+ expect(out).toBe("Updated on 15.01.");
+ });
+
+ it("returns empty for an invalid timestamp", () => {
+ expect(formatUpdatedLabel("not-a-date", t, fmtDate, fmtTime, "UTC")).toBe(
+ "",
+ );
+ });
+});
diff --git a/src/lib/i18n/relative-time.ts b/src/lib/i18n/relative-time.ts
index 3560ed1f9..384ed09b1 100644
--- a/src/lib/i18n/relative-time.ts
+++ b/src/lib/i18n/relative-time.ts
@@ -86,3 +86,47 @@ export function relativeCalendarDate(
return t("medications.yesterday");
return formatDate(target);
}
+
+/**
+ * v1.22 (W6) — "Updated …" freshness label for the insight surfaces.
+ *
+ * Replaces the bare relative read ("2 hours ago") on the briefing + per-metric
+ * cards with a calendar-bucketed label that shows the TIME only for today:
+ * - today → "Updated today, 14:30" (time via the locale formatter)
+ * - yesterday → "Updated yesterday" (no time)
+ * - older → "Updated on 14.06." (date-only, honours the user's
+ * DateFormatPreference via the passed `formatDate`)
+ *
+ * Day-bucketing uses an `en-CA` (YYYY-MM-DD) key formatted in `timeZone` so the
+ * boundary follows the user's zone, matching `relativeCalendarDate`. `formatDate`
+ * + `formatTime` come from the locale formatters hook (`useFormatters` →
+ * `fmt.dateShort` / `fmt.time`), so both honour the user's locale + preferences.
+ */
+export function formatUpdatedLabel(
+ iso: string,
+ t: (key: string, params?: Record) => string,
+ formatDate: (value: Date) => string,
+ formatTime: (value: Date) => string,
+ timeZone?: string,
+): string {
+ const target = new Date(iso);
+ if (Number.isNaN(target.getTime())) return "";
+ const dayFormatter = new Intl.DateTimeFormat("en-CA", {
+ timeZone,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ });
+ const now = new Date();
+ const yesterday = new Date(now);
+ yesterday.setDate(yesterday.getDate() - 1);
+
+ const targetDay = dayFormatter.format(target);
+ if (targetDay === dayFormatter.format(now)) {
+ return t("insights.updatedTodayAt", { time: formatTime(target) });
+ }
+ if (targetDay === dayFormatter.format(yesterday)) {
+ return t("insights.updatedYesterday");
+ }
+ return t("insights.updatedOn", { date: formatDate(target) });
+}