diff --git a/messages/de.json b/messages/de.json index 4c02c9b4e..a1ef194b3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2122,6 +2122,8 @@ "critical": "Kritisch" }, "assessmentTitle": "Einschätzung", + "assessmentShowMore": "Mehr anzeigen", + "assessmentShowLess": "Weniger anzeigen", "bloodPressureSectionTitle": "Blutdruck", "weightSectionTitle": "Gewicht", "pulseSectionTitle": "Puls", diff --git a/messages/en.json b/messages/en.json index b8a03d7ef..c5b164939 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2122,6 +2122,8 @@ "critical": "Critical" }, "assessmentTitle": "Assessment", + "assessmentShowMore": "Show more", + "assessmentShowLess": "Show less", "bloodPressureSectionTitle": "Blood Pressure", "weightSectionTitle": "Weight", "pulseSectionTitle": "Pulse", diff --git a/messages/es.json b/messages/es.json index 952da3362..9103c1a91 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index c609638e0..282e00420 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2122,6 +2122,8 @@ "critical": "Critique" }, "assessmentTitle": "Évaluation", + "assessmentShowMore": "Afficher plus", + "assessmentShowLess": "Afficher moins", "bloodPressureSectionTitle": "Tension artérielle", "weightSectionTitle": "Poids", "pulseSectionTitle": "Pouls", diff --git a/messages/it.json b/messages/it.json index 5932fb12e..7cf07cb29 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2122,6 +2122,8 @@ "critical": "Critico" }, "assessmentTitle": "Valutazione", + "assessmentShowMore": "Mostra altro", + "assessmentShowLess": "Mostra meno", "bloodPressureSectionTitle": "Pressione arteriosa", "weightSectionTitle": "Peso", "pulseSectionTitle": "Polso", diff --git a/messages/pl.json b/messages/pl.json index 05b1d234a..f8875743b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2122,6 +2122,8 @@ "critical": "Krytyczny" }, "assessmentTitle": "Ocena", + "assessmentShowMore": "Pokaż więcej", + "assessmentShowLess": "Pokaż mniej", "bloodPressureSectionTitle": "Ciśnienie krwi", "weightSectionTitle": "Waga", "pulseSectionTitle": "Tętno", diff --git a/src/components/insights/__tests__/insight-status-card.test.tsx b/src/components/insights/__tests__/insight-status-card.test.tsx index 84454479b..da2994d2b 100644 --- a/src/components/insights/__tests__/insight-status-card.test.tsx +++ b/src/components/insights/__tests__/insight-status-card.test.tsx @@ -90,6 +90,36 @@ describe("", () => { 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( + , + ); + 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( + , + ); + 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( , diff --git a/src/components/insights/insight-status-card.tsx b/src/components/insights/insight-status-card.tsx index 66d9561c6..c728988a3 100644 --- a/src/components/insights/insight-status-card.tsx +++ b/src/components/insights/insight-status-card.tsx @@ -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" > -
-
- {icon} - {title} -
- {cached && ( - - {t("insights.cached")} - - )} + {/* 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. */} +
+ {icon} + {title}
@@ -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 ( -

{text}

- ); - } - // 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(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 (

{text}

- + {/* 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) && ( + + )}
); } @@ -258,7 +284,9 @@ function LastUpdatedFooter({ updatedAt }: { updatedAt: string | null }) { const fmt = useFormatters(); if (!updatedAt) return null; return ( -

+ // 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. +

{t("insights.lastUpdated")}: {fmt.dateTime(updatedAt)}

);