fix(insights): tidy the assessment card and gate its show-more toggle

Drop the top-right "cached" label from the assessment card — it surfaced
an implementation detail and read as if the insight were second-hand. The
warm cache still backs the text; the card just no longer announces it.

Mount the "show more" toggle only when the clamped prose actually
overflows three lines, measured against the live layout via a
ResizeObserver instead of a character-count guess. Short assessments that
fit no longer grow a toggle that reveals nothing, and text that wraps past
three lines on a narrow column now gets one.

Move the toggle copy off the inline locale-prefix fallback onto proper
`insights.assessmentShowMore` / `insights.assessmentShowLess` keys across
all six locales, and right-align the "last updated" line.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 18:20:38 +02:00
1 parent 4e6599d85f
commit 252921b563
8 files changed
+117 -47

No files matched your search

+2
View File
@@ -2122,6 +2122,8 @@
"critical": "Kritisch"
},
"assessmentTitle": "Einschätzung",
"assessmentShowMore": "Mehr anzeigen",
"assessmentShowLess": "Weniger anzeigen",
"bloodPressureSectionTitle": "Blutdruck",
"weightSectionTitle": "Gewicht",
"pulseSectionTitle": "Puls",
+2
View File
@@ -2122,6 +2122,8 @@
"critical": "Critical"
},
"assessmentTitle": "Assessment",
"assessmentShowMore": "Show more",
"assessmentShowLess": "Show less",
"bloodPressureSectionTitle": "Blood Pressure",
"weightSectionTitle": "Weight",
"pulseSectionTitle": "Pulse",
+2
View File
@@ -2122,6 +2122,8 @@
"critical": "Crítico"
},
"assessmentTitle": "Valoración",
"assessmentShowMore": "Mostrar más",
"assessmentShowLess": "Mostrar menos",
"bloodPressureSectionTitle": "Presión arterial",
"weightSectionTitle": "Peso",
"pulseSectionTitle": "Pulso",
+2
View File
@@ -2122,6 +2122,8 @@
"critical": "Critique"
},
"assessmentTitle": "Évaluation",
"assessmentShowMore": "Afficher plus",
"assessmentShowLess": "Afficher moins",
"bloodPressureSectionTitle": "Tension artérielle",
"weightSectionTitle": "Poids",
"pulseSectionTitle": "Pouls",
+2
View File
@@ -2122,6 +2122,8 @@
"critical": "Critico"
},
"assessmentTitle": "Valutazione",
"assessmentShowMore": "Mostra altro",
"assessmentShowLess": "Mostra meno",
"bloodPressureSectionTitle": "Pressione arteriosa",
"weightSectionTitle": "Peso",
"pulseSectionTitle": "Polso",
+2
View File
@@ -2122,6 +2122,8 @@
"critical": "Krytyczny"
},
"assessmentTitle": "Ocena",
"assessmentShowMore": "Pokaż więcej",
"assessmentShowLess": "Pokaż mniej",
"bloodPressureSectionTitle": "Ciśnienie krwi",
"weightSectionTitle": "Waga",
"pulseSectionTitle": "Tętno",
@@ -90,6 +90,36 @@ describe("<InsightStatusCard>", () => {
expect(html).toContain("No analysis yet.");
});
it("never surfaces a cached badge, even when the text came from the warm cache", () => {
// v1.11.5 — the top-right "cached" label was removed. The caching
// behaviour is unchanged (the prop is still threaded), but the card
// no longer announces it, so the assessment reads as authoritative.
const html = render(
<InsightStatusCard
{...baseProps}
cached
text="Pulse stayed inside the band."
/>,
);
expect(html).toContain("Pulse stayed inside the band.");
expect(html).not.toContain("Cached");
});
it("does not paint the show-more toggle in SSR (overflow is measured client-side)", () => {
// v1.11.5 — the toggle mounts only on a measured three-line overflow,
// computed in a layout effect that never runs under SSR, so the
// server output carries the prose but no toggle. A short assessment
// that fits never grows a useless affordance.
const html = render(
<InsightStatusCard
{...baseProps}
text="A short assessment that comfortably fits inside three lines."
/>,
);
expect(html).toContain("A short assessment");
expect(html).not.toContain('data-slot="assessment-show-more"');
});
it("renders the structured skeleton (v1.4.37) when loading instead of a centred spinner", () => {
const html = render(
<InsightStatusCard {...baseProps} text={null} loading />,
+75 -47
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
@@ -15,6 +15,13 @@ interface InsightStatusCardProps {
icon: React.ReactNode;
text: string | null;
hasProvider: boolean;
/**
* Whether the rendered text came from the warm cache rather than a
* fresh generation. Retained on the contract so every caller can keep
* threading it, but the card no longer surfaces a "cached" badge — the
* label devalued the assessment and the caching is an implementation
* detail the user does not need to see.
*/
cached: boolean;
updatedAt: string | null;
loading?: boolean;
@@ -34,7 +41,6 @@ export function InsightStatusCard({
icon,
text,
hasProvider,
cached,
updatedAt,
loading = false,
preparing = false,
@@ -171,16 +177,12 @@ export function InsightStatusCard({
className="animate-insight-in gap-2 py-4 md:gap-3 md:py-5"
>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{icon}
<CardTitle className="text-base">{title}</CardTitle>
</div>
{cached && (
<span className="text-muted-foreground text-xs">
{t("insights.cached")}
</span>
)}
{/* v1.11.5 — the top-right "cached" label was removed: it surfaced
an implementation detail and devalued the assessment. The card
still consumes the warm cache; it just no longer announces it. */}
<div className="flex items-center gap-2">
{icon}
<CardTitle className="text-base">{title}</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-2">
@@ -202,35 +204,51 @@ export function InsightStatusCard({
* v1.4.27 MB7 / CF-39 — collapsible status copy. The assessment text
* routinely lands at 3-5 paragraphs of dense narrative which dominates
* the surface on phones. The default collapses to 3 lines via
* `line-clamp-3`; a "Show more" toggle reveals the full body. Desktop
* users see the full text immediately because the clamp + toggle only
* mount when the source string is long enough to actually clip.
* `line-clamp-3`; a "Show more" toggle reveals the full body.
*
* v1.11.5 — the toggle now mounts only when the clamped prose actually
* overflows three lines, measured against the live layout rather than a
* character-count guess. The previous ~220-character heuristic painted a
* "Show more" affordance on text that already fit (and could miss text
* that wrapped past three lines on a narrow column), so a tap revealed
* nothing. We compare the paragraph's `scrollHeight` against its
* `clientHeight` while clamped; the toggle renders only on a true
* overflow, and re-measures on resize / font load via `ResizeObserver`.
*/
function StatusBody({ text }: { text: string }) {
const { locale } = useTranslations();
const { t } = useTranslations();
const [expanded, setExpanded] = useState(false);
// Cheap heuristic — only mount the toggle when the text is long
// enough that the 3-line clamp will hide content. ~220 characters
// covers the worst-case sm-viewport line at typical insights text
// density; below that we render plain so the user never sees a
// useless "Show more" affordance.
const isLong = text.length > 220;
if (!isLong) {
return (
<p className="text-muted-foreground text-sm leading-relaxed">{text}</p>
);
}
// v1.4.27 MB7 / CF-39 — toggle copy is two short strings; rather
// than reach into the catalogue (which this bucket cannot edit per
// the dispatch brief), the labels resolve against the locale prefix
// inline. The translation catalogue can claim these strings in a
// later cycle without breaking the contract.
const isDe = locale.startsWith("de");
const showMoreLabel = isDe ? "Mehr anzeigen" : "Show more";
const showLessLabel = isDe ? "Weniger anzeigen" : "Show less";
const [overflows, setOverflows] = useState(false);
const paragraphRef = useRef<HTMLParagraphElement | 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".
useEffect(() => {
if (expanded) return;
const node = paragraphRef.current;
if (!node) return;
const measure = () => {
setOverflows(node.scrollHeight > node.clientHeight + 1);
};
measure();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measure);
observer.observe(node);
return () => observer.disconnect();
// Re-measure whenever the source text changes (a fresh assessment) or
// the user collapses the card again.
}, [text, expanded]);
return (
<div className="space-y-1">
<p
ref={paragraphRef}
className={cn(
"text-muted-foreground text-sm leading-relaxed",
!expanded && "line-clamp-3",
@@ -238,17 +256,25 @@ function StatusBody({ text }: { text: string }) {
>
{text}
</p>
<button
type="button"
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
className={cn(
"text-foreground/80 hover:text-foreground inline-flex text-xs font-medium",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
)}
>
{expanded ? showLessLabel : showMoreLabel}
</button>
{/* 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. */}
{(overflows || expanded) && (
<button
type="button"
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
data-slot="assessment-show-more"
className={cn(
"text-foreground/80 hover:text-foreground inline-flex text-xs font-medium",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
)}
>
{expanded
? t("insights.assessmentShowLess")
: t("insights.assessmentShowMore")}
</button>
)}
</div>
);
}
@@ -258,7 +284,9 @@ function LastUpdatedFooter({ updatedAt }: { updatedAt: string | null }) {
const fmt = useFormatters();
if (!updatedAt) return null;
return (
<p className="text-muted-foreground text-xs">
// v1.11.5 — right-aligned so the timestamp tucks to the trailing edge
// of the card for a tidier read against the left-aligned prose above.
<p className="text-muted-foreground text-right text-xs">
{t("insights.lastUpdated")}: {fmt.dateTime(updatedAt)}
</p>
);