mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +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",
|
"sourcesFresh": "Aktuell",
|
||||||
"sourcesStale": "Veraltet",
|
"sourcesStale": "Veraltet",
|
||||||
"sourcesFooter": "Der Coach liest nur Daten, die du verbunden hast.",
|
"sourcesFooter": "Der Coach liest nur Daten, die du verbunden hast.",
|
||||||
|
"evidenceLabel": "Worauf bezieht sich das?",
|
||||||
"metric": {
|
"metric": {
|
||||||
"bp": "Blutdruck",
|
"bp": "Blutdruck",
|
||||||
"weight": "Gewicht",
|
"weight": "Gewicht",
|
||||||
|
|||||||
@@ -871,6 +871,7 @@
|
|||||||
"sourcesFresh": "Fresh",
|
"sourcesFresh": "Fresh",
|
||||||
"sourcesStale": "Stale",
|
"sourcesStale": "Stale",
|
||||||
"sourcesFooter": "The Coach reads only the data you've connected.",
|
"sourcesFooter": "The Coach reads only the data you've connected.",
|
||||||
|
"evidenceLabel": "What I'm looking at",
|
||||||
"metric": {
|
"metric": {
|
||||||
"bp": "Blood pressure",
|
"bp": "Blood pressure",
|
||||||
"weight": "Weight",
|
"weight": "Weight",
|
||||||
|
|||||||
@@ -216,4 +216,107 @@ describe("<MessageThread>", () => {
|
|||||||
"Frag mich etwas zu deinen Trends, Medikamenten oder Messwerten",
|
"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";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
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 { cn } from "@/lib/utils";
|
||||||
import { useTranslations } from "@/lib/i18n/context";
|
import { useTranslations } from "@/lib/i18n/context";
|
||||||
@@ -202,6 +202,8 @@ function ChatBubble({
|
|||||||
? t("insights.coach.errorProvider")
|
? t("insights.coach.errorProvider")
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const keyValues = metricSource?.keyValues ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="coach-bubble-assistant"
|
data-slot="coach-bubble-assistant"
|
||||||
@@ -232,6 +234,55 @@ function ChatBubble({
|
|||||||
<p className="text-dracula-orange/90 text-xs">{safeError}</p>
|
<p className="text-dracula-orange/90 text-xs">{safeError}</p>
|
||||||
)}
|
)}
|
||||||
{metricSource && <SourceChips provenance={metricSource} />}
|
{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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user