feat(insights): localized Coach seeds, freshness labels, paragraph rendering

Route the composer seed questions through i18n so a German user no longer
gets an English opener when tapping Ask the Coach from a metric page or an
insight card. Replace the relative timestamp on the briefing and per-metric
cards with a freshness label that shows the time only for today, drops it for
yesterday, and renders a locale date honouring the user's date-format
preference for older entries. Render the briefing paragraph, the per-metric
and score assessments, and the period narrative through the shared paragraph
helper so longer prose shows real paragraphs instead of one run-on block.

Adds the insights.coach.seed.* and insights.updated* keys (English + German).
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 14:40:18 +02:00
1 parent 5674f10b2f
commit 10111852a4
15 files changed
+253 -88

No files matched your search

+31 -1
View File
@@ -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",
+31 -1
View File
@@ -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",
@@ -119,13 +119,15 @@ describe("<DailyBriefing>", () => {
expect(html).toContain("bg-info"); // tone=info bar
});
it("renders the 'Generated <time>' caption when updatedAt is supplied", () => {
it("renders the 'Updated today, <time>' caption when updatedAt is supplied", () => {
// v1.22 (W6) — the footer now uses `formatUpdatedLabel`: a same-day
// timestamp reads "Updated today, HH:MM".
const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString();
const html = render(
<DailyBriefing briefing={baseBriefing} updatedAt={fiveMinutesAgo} />,
);
expect(html).toMatch(/data-slot="daily-briefing-updated"/);
expect(html).toContain("Generated");
expect(html).toContain("Updated today");
});
it("does NOT render the updated caption when updatedAt is missing", () => {
+22 -35
View File
@@ -17,10 +17,10 @@ import { COACH_SOURCE_DOMAIN_LABEL } from "@/lib/ai/coach/tools/source-keys";
* - the "Ask the Coach" affordance on insight/assessment cards passes
* an explicit scope so a card tap lands a pre-scoped conversation.
*
* Seed questions are plain English strings, mirroring the empty-state
* `coachPrefill` convention already in the tree — the Coach chat route
* is English/German-gated and treats the prefill as composer seed text,
* not an i18n key.
* v1.22 (W6) — seed questions are i18n KEYS (`insights.coach.seed.*`), not
* hardcoded English: a German user was getting an English composer seed. The
* consumer (`SubPageShell`) resolves the key through `t()` before handing it to
* the composer, so the seed reads in the user's language.
*/
export interface CoachMetricScope {
@@ -30,7 +30,7 @@ export interface CoachMetricScope {
also?: CoachScopeSource[];
/** Optional day-window override; defaults to the route's `last30days`. */
window?: CoachScopeWindow;
/** Composer seed question — a data-aware opener for the metric. */
/** i18n key for the composer seed question — resolved by the consumer. */
question: string;
}
@@ -45,79 +45,67 @@ export interface CoachMetricScope {
const EXPLAINER_METRIC_SCOPE: Record<string, CoachMetricScope> = {
bloodPressure: {
metric: "bp",
question:
"Walk me through my blood pressure trend over the last 30 days — anything I should keep an eye on?",
question: "insights.coach.seed.bloodPressure",
},
weight: {
metric: "weight",
question:
"How has my weight been trending lately, and is the direction something to act on?",
question: "insights.coach.seed.weight",
},
bmi: {
metric: "bmi",
question: "What does my BMI trend tell me, and how should I read it?",
question: "insights.coach.seed.bmi",
},
pulse: {
metric: "pulse",
question:
"Walk me through my pulse readings — is anything out of the ordinary?",
question: "insights.coach.seed.pulse",
},
restingHr: {
metric: "resting_hr",
question:
"How has my resting heart rate been trending, and what does it say about my fitness?",
question: "insights.coach.seed.restingHr",
},
hrv: {
metric: "hrv",
question:
"What is my heart-rate variability telling me about recovery and stress lately?",
question: "insights.coach.seed.hrv",
},
sleep: {
metric: "sleep",
question:
"Walk me through my recent sleep — duration, consistency, and anything worth changing.",
question: "insights.coach.seed.sleep",
},
mood: {
metric: "mood",
question: "What patterns do you see in my mood over the last few weeks?",
question: "insights.coach.seed.mood",
},
medications: {
metric: "compliance",
question:
"How is my medication adherence looking, and is it lined up with how I've been feeling?",
question: "insights.coach.seed.medications",
},
steps: {
metric: "steps",
question:
"How active have I been lately based on my steps, and how does it compare to a healthy baseline?",
question: "insights.coach.seed.steps",
},
activeEnergy: {
metric: "active_energy",
question:
"What does my active-energy trend say about my activity level lately?",
question: "insights.coach.seed.activeEnergy",
},
cardioFitness: {
metric: "vo2_max",
question:
"What does my cardio fitness (VO₂ max) trend mean, and how do I move it in the right direction?",
question: "insights.coach.seed.cardioFitness",
},
bloodGlucose: {
metric: "glucose",
question:
"Walk me through my recent glucose readings — are they in a healthy range?",
question: "insights.coach.seed.bloodGlucose",
},
oxygenSaturation: {
metric: "spo2",
question: "What does my blood-oxygen (SpO₂) trend tell me?",
question: "insights.coach.seed.oxygenSaturation",
},
respiratoryRate: {
metric: "respiratory_rate",
question: "What does my respiratory-rate trend say about my health?",
question: "insights.coach.seed.respiratoryRate",
},
workouts: {
metric: "workouts",
question:
"Walk me through my recent workouts — load, frequency, and how I'm recovering between them.",
question: "insights.coach.seed.workouts",
},
// Recovery is a synthesis page; anchor on HRV + resting HR + sleep so the
// Coach reads the inputs that drive the recovery read.
@@ -125,8 +113,7 @@ const EXPLAINER_METRIC_SCOPE: Record<string, CoachMetricScope> = {
metric: "hrv",
also: ["resting_hr", "sleep"],
window: "last7days",
question:
"Why is my recovery where it is right now, and what's driving it?",
question: "insights.coach.seed.recoveryPage",
},
};
@@ -44,7 +44,8 @@ describe("metricScopeFromExplainer", () => {
it("resolves a mapped explainer token to a scope + opener", () => {
const resolved = metricScopeFromExplainer("bloodPressure");
expect(resolved?.metric).toBe("bp");
expect(resolved?.question).toMatch(/blood pressure/i);
// v1.22 (W6) — `question` is now an i18n key (resolved by the consumer).
expect(resolved?.question).toBe("insights.coach.seed.bloodPressure");
});
it("anchors the recovery page on its driver sources + short window", () => {
+5 -7
View File
@@ -85,13 +85,11 @@ const COACH_SOURCES_BY_KIND: Record<
"weight-weekday": ["weight"],
};
// v1.22 (W6) — i18n keys (resolved at the call site), not hardcoded English.
const COACH_QUESTION_BY_KIND: Record<CorrelationResult["kind"], string> = {
"bp-compliance":
"How does my medication adherence relate to my blood pressure? Walk me through what this correlation means for me.",
"mood-pulse":
"Is there a real link between my mood and my pulse? Help me read this correlation.",
"weight-weekday":
"Does my weight follow a weekly pattern? What does this day-of-week correlation tell me?",
"bp-compliance": "insights.coach.seed.correlationBpCompliance",
"mood-pulse": "insights.coach.seed.correlationMoodPulse",
"weight-weekday": "insights.coach.seed.correlationWeightWeekday",
};
export function CorrelationCard({ result }: CorrelationCardProps) {
@@ -183,7 +181,7 @@ export function CorrelationCard({ result }: CorrelationCardProps) {
{/* v1.21.0 (C4 H2) — the live Coach hand-off sits beside the
roadmap CTA: it scopes to both sources in the pair. */}
<AskCoachAction
question={COACH_QUESTION_BY_KIND[result.kind]}
question={t(COACH_QUESTION_BY_KIND[result.kind])}
scope={coachScope}
/>
</div>
+10 -6
View File
@@ -26,8 +26,8 @@ import { ListRow } from "@/components/ui/list-row";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionHeading } from "@/components/insights/section-heading";
import { AskCoachAction } from "@/components/insights/ask-coach-action";
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 { cn } from "@/lib/utils";
import type {
@@ -352,6 +352,7 @@ export function DailyBriefing({
memory = null,
}: DailyBriefingProps) {
const { t } = useTranslations();
const fmt = useFormatters();
return (
// v1.13.1 — heading-above-card pattern. The "Tagesbriefing" title
@@ -472,9 +473,12 @@ export function DailyBriefing({
data-slot="daily-briefing-updated"
className="text-muted-foreground text-right text-[11px]"
>
{t("insights.heroGenerated", {
time: formatRelativeTime(updatedAt, t),
})}
{formatUpdatedLabel(
updatedAt,
t,
fmt.dateShort,
fmt.time,
)}
</p>
)}
{/* 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. */}
<div className="flex justify-end">
<AskCoachAction question="Walk me through today's briefing — what should I focus on, and is anything worth acting on?" />
<AskCoachAction question={t("insights.coach.seed.briefing")} />
</div>
</div>
) : noProvider ? (
@@ -714,7 +714,7 @@ export function HealthScoreCard({
a single discreet entry, not a primary CTA. */}
<div className="mt-auto flex justify-end pt-1">
<AskCoachAction
question={`Why is my health score ${score} out of 100, and what would move it the most?`}
question={t("insights.coach.seed.healthScore", { score })}
/>
</div>
</div>
+8 -4
View File
@@ -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}
</h1>
</div>
<p
{/* v1.22 (W6) — the briefing paragraph renders through ProseBlocks
so a multi-paragraph verdict-first briefing shows real paragraph
spacing (still plain text children, no markdown). */}
<div
data-slot="insights-hero-strip-subtitle"
className="text-muted-foreground max-w-3xl text-sm leading-relaxed"
className="text-muted-foreground max-w-3xl text-sm"
>
{subtitle}
</p>
<ProseBlocks text={subtitle} />
</div>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span data-slot="insights-hero-strip-baseline">
{t("insights.heroPersonalBaseline")}
+21 -17
View File
@@ -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<HTMLParagraphElement | null>(null);
const wrapperRef = useRef<HTMLDivElement | null>(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
// `<p>` 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 (
<div className="space-y-1">
<p
ref={paragraphRef}
<div
ref={wrapperRef}
className={cn(
"text-muted-foreground text-sm leading-relaxed",
!expanded && "line-clamp-3",
"text-muted-foreground text-sm",
// ~3 lines at text-sm / leading-relaxed; only when collapsed.
!expanded && "max-h-[4.5rem] overflow-hidden",
)}
>
{text}
</p>
<ProseBlocks text={text} />
</div>
{/* 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.
<p className="text-muted-foreground text-right text-xs">
{t("insights.lastUpdated")}: {formatRelativeTime(updatedAt, t)}
{formatUpdatedLabel(updatedAt, t, fmt.dateShort, fmt.time)}
</p>
);
}
@@ -9,6 +9,7 @@ import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import { SectionHeading } from "@/components/insights/section-heading";
import { AskCoachAction } from "@/components/insights/ask-coach-action";
import { ProseBlocks } from "@/components/insights/prose-blocks";
import { apiGet } from "@/lib/api/api-fetch";
/**
@@ -194,10 +195,11 @@ export function PeriodNarrativeCard({
{t("insights.narrativePreparing")}
</p>
) : (
// Plain text child — NO markdown renderer (XSS posture, CLAUDE.md).
<p className="text-foreground text-sm leading-relaxed whitespace-pre-line">
{narrative?.text}
</p>
// v1.22 (W6) — real paragraphs via the shared ProseBlocks helper
// (still plain text children — NO markdown renderer, XSS posture).
<div className="text-foreground text-sm">
<ProseBlocks text={narrative?.text ?? ""} />
</div>
)}
{narrative?.provenance ? (
@@ -222,11 +224,11 @@ export function PeriodNarrativeCard({
{narrative ? (
<div className="flex justify-end">
<AskCoachAction
question={
question={t(
period === "week"
? "Walk me through how my last week went across my health metrics — what stands out?"
: "Walk me through how my last month went across my health metrics — what stands out?"
}
? "insights.coach.seed.periodWeek"
: "insights.coach.seed.periodMonth",
)}
/>
</div>
) : null}
@@ -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 (
<div
+4 -2
View File
@@ -202,9 +202,11 @@ export function SubPageShell({
also: resolved.also,
window: resolved.window,
},
resolved.question,
// v1.22 (W6) — `question` is now an i18n key; resolve it so the composer
// seed reads in the user's language.
t(resolved.question),
);
}, [coachLaunch, registerScope, explainerMetric]);
}, [coachLaunch, registerScope, explainerMetric, t]);
return (
// The target-adjust provider bridges the header gear (rendered just
@@ -0,0 +1,57 @@
/**
* v1.22 (W6) — `formatUpdatedLabel`: time only for today, no time for
* yesterday, locale date for older (honouring the passed formatter).
*/
import { describe, expect, it } from "vitest";
import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
const t = (key: string, params?: Record<string, string | number>): 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(
"",
);
});
});
+44
View File
@@ -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, string | number>) => 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) });
}