mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
feat(coach): collapsible evidence-block disclosure under assistant bubbles
Render the v1.4.22 keyValues array as a click-to-expand "Worauf bezieht sich das?" / "What I'm looking at" disclosure under each assistant message. Closed by default; rows show label, value, unit, and window with Dracula muted-foreground accents and a chevron that rotates on open. Source chips stay above the disclosure so users see both abstraction layers, and the whole block hides when keyValues is empty. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
7b8efaae14
commit
bc87fa3904
4 files changed
+157
-1
No files matched your search
@@ -871,6 +871,7 @@
|
||||
"sourcesFresh": "Aktuell",
|
||||
"sourcesStale": "Veraltet",
|
||||
"sourcesFooter": "Der Coach liest nur Daten, die du verbunden hast.",
|
||||
"evidenceLabel": "Worauf bezieht sich das?",
|
||||
"metric": {
|
||||
"bp": "Blutdruck",
|
||||
"weight": "Gewicht",
|
||||
|
||||
@@ -871,6 +871,7 @@
|
||||
"sourcesFresh": "Fresh",
|
||||
"sourcesStale": "Stale",
|
||||
"sourcesFooter": "The Coach reads only the data you've connected.",
|
||||
"evidenceLabel": "What I'm looking at",
|
||||
"metric": {
|
||||
"bp": "Blood pressure",
|
||||
"weight": "Weight",
|
||||
|
||||
@@ -216,4 +216,107 @@ describe("<MessageThread>", () => {
|
||||
"Frag mich etwas zu deinen Trends, Medikamenten oder Messwerten",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the evidence-block disclosure when keyValues is non-empty (EN)", () => {
|
||||
// v1.4.22 — the Coach surfaces load-bearing numbers in a
|
||||
// collapsible "What I'm looking at" disclosure under the assistant
|
||||
// bubble. Verify the structure renders correctly and the entries
|
||||
// show label / value / unit / window.
|
||||
const withKeyValues: CoachConversationDetailDTO = {
|
||||
...baseConversation,
|
||||
messages: [
|
||||
baseConversation.messages[0],
|
||||
{
|
||||
...baseConversation.messages[1],
|
||||
metricSource: {
|
||||
windows: ["last30days"],
|
||||
metrics: ["bp"],
|
||||
keyValues: [
|
||||
{
|
||||
label: "avg7 systolic",
|
||||
value: "138",
|
||||
unit: "mmHg",
|
||||
window: "last7days",
|
||||
},
|
||||
{
|
||||
label: "avg30 systolic",
|
||||
value: "134",
|
||||
unit: "mmHg",
|
||||
window: "last30days",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const html = render(<MessageThread conversation={withKeyValues} />);
|
||||
expect(html).toContain('data-slot="coach-evidence"');
|
||||
expect(html).toContain("What I'm looking at");
|
||||
expect(html).toContain('data-slot="coach-evidence-list"');
|
||||
const rows = (html.match(/data-slot="coach-evidence-row"/g) ?? []).length;
|
||||
expect(rows).toBe(2);
|
||||
expect(html).toContain("avg7 systolic");
|
||||
expect(html).toContain("138 mmHg");
|
||||
expect(html).toContain("(last7days)");
|
||||
expect(html).toContain("avg30 systolic");
|
||||
expect(html).toContain("134 mmHg");
|
||||
// Disclosure is collapsed by default (no `open` attribute).
|
||||
expect(html).not.toMatch(/<details[^>]*\bopen\b/);
|
||||
});
|
||||
|
||||
it("renders the evidence-block label in German under 'de' locale", () => {
|
||||
const withKeyValues: CoachConversationDetailDTO = {
|
||||
...baseConversation,
|
||||
messages: [
|
||||
baseConversation.messages[0],
|
||||
{
|
||||
...baseConversation.messages[1],
|
||||
metricSource: {
|
||||
windows: ["last30days"],
|
||||
metrics: ["bp"],
|
||||
keyValues: [
|
||||
{
|
||||
label: "avg30 systolisch",
|
||||
value: "138",
|
||||
unit: "mmHg",
|
||||
window: "last30days",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const html = render(<MessageThread conversation={withKeyValues} />, "de");
|
||||
expect(html).toContain("Worauf bezieht sich das?");
|
||||
});
|
||||
|
||||
it("renders entries without unit or window cleanly", () => {
|
||||
const withKeyValues: CoachConversationDetailDTO = {
|
||||
...baseConversation,
|
||||
messages: [
|
||||
baseConversation.messages[0],
|
||||
{
|
||||
...baseConversation.messages[1],
|
||||
metricSource: {
|
||||
windows: [],
|
||||
metrics: ["compliance"],
|
||||
keyValues: [{ label: "30-day adherence", value: "96" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const html = render(<MessageThread conversation={withKeyValues} />);
|
||||
expect(html).toContain("30-day adherence");
|
||||
// No stray empty unit/window markup.
|
||||
expect(html).not.toMatch(/30-day adherence:\s*<\/span>\s*<strong[^>]*>\s*</);
|
||||
});
|
||||
|
||||
it("hides the disclosure entirely when keyValues is empty or absent", () => {
|
||||
// baseConversation.messages[1].metricSource has no `keyValues`
|
||||
// field — the source chips still render but the disclosure does
|
||||
// not appear at all.
|
||||
const html = render(<MessageThread conversation={baseConversation} />);
|
||||
expect(html).not.toContain('data-slot="coach-evidence"');
|
||||
expect(html).not.toContain("What I'm looking at");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Bot, Sparkles, User } from "lucide-react";
|
||||
import { Bot, ChevronRight, Sparkles, User } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -202,6 +202,8 @@ function ChatBubble({
|
||||
? t("insights.coach.errorProvider")
|
||||
: null;
|
||||
|
||||
const keyValues = metricSource?.keyValues ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-bubble-assistant"
|
||||
@@ -232,6 +234,55 @@ function ChatBubble({
|
||||
<p className="text-dracula-orange/90 text-xs">{safeError}</p>
|
||||
)}
|
||||
{metricSource && <SourceChips provenance={metricSource} />}
|
||||
{keyValues.length > 0 && (
|
||||
<details
|
||||
data-slot="coach-evidence"
|
||||
className={cn(
|
||||
"border-border/50 bg-muted/30 group rounded-md border",
|
||||
"px-2.5 py-1.5 text-xs",
|
||||
)}
|
||||
>
|
||||
<summary
|
||||
data-slot="coach-evidence-summary"
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground flex cursor-pointer",
|
||||
"items-center gap-1.5 leading-relaxed",
|
||||
"[&::-webkit-details-marker]:hidden marker:hidden",
|
||||
"focus-visible:ring-ring/50 rounded outline-none focus-visible:ring-2",
|
||||
)}
|
||||
>
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
className="size-3 transition-transform group-open:rotate-90"
|
||||
/>
|
||||
<span>{t("insights.coach.evidenceLabel")}</span>
|
||||
</summary>
|
||||
<ul
|
||||
data-slot="coach-evidence-list"
|
||||
className="text-foreground mt-2 flex flex-col gap-1"
|
||||
>
|
||||
{keyValues.map((kv, idx) => (
|
||||
<li
|
||||
key={`${kv.label}-${idx}`}
|
||||
data-slot="coach-evidence-row"
|
||||
className="leading-relaxed"
|
||||
>
|
||||
<span className="text-muted-foreground">{kv.label}:</span>{" "}
|
||||
<strong className="font-semibold">
|
||||
{kv.value}
|
||||
{kv.unit ? ` ${kv.unit}` : ""}
|
||||
</strong>
|
||||
{kv.window && (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
({kv.window})
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user