mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
1 parent
5674f10b2f
commit
10111852a4
15 files changed
+253
-88
No files matched your search
+31
-1
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
"",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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) });
|
||||
}
|
||||
Reference in new issue
Block a user