mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
4e6599d85f
commit
252921b563
8 files changed
+117
-47
No files matched your search
@@ -2122,6 +2122,8 @@
|
||||
"critical": "Kritisch"
|
||||
},
|
||||
"assessmentTitle": "Einschätzung",
|
||||
"assessmentShowMore": "Mehr anzeigen",
|
||||
"assessmentShowLess": "Weniger anzeigen",
|
||||
"bloodPressureSectionTitle": "Blutdruck",
|
||||
"weightSectionTitle": "Gewicht",
|
||||
"pulseSectionTitle": "Puls",
|
||||
|
||||
@@ -2122,6 +2122,8 @@
|
||||
"critical": "Critical"
|
||||
},
|
||||
"assessmentTitle": "Assessment",
|
||||
"assessmentShowMore": "Show more",
|
||||
"assessmentShowLess": "Show less",
|
||||
"bloodPressureSectionTitle": "Blood Pressure",
|
||||
"weightSectionTitle": "Weight",
|
||||
"pulseSectionTitle": "Pulse",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2122,6 +2122,8 @@
|
||||
"critical": "Critique"
|
||||
},
|
||||
"assessmentTitle": "Évaluation",
|
||||
"assessmentShowMore": "Afficher plus",
|
||||
"assessmentShowLess": "Afficher moins",
|
||||
"bloodPressureSectionTitle": "Tension artérielle",
|
||||
"weightSectionTitle": "Poids",
|
||||
"pulseSectionTitle": "Pouls",
|
||||
|
||||
@@ -2122,6 +2122,8 @@
|
||||
"critical": "Critico"
|
||||
},
|
||||
"assessmentTitle": "Valutazione",
|
||||
"assessmentShowMore": "Mostra altro",
|
||||
"assessmentShowLess": "Mostra meno",
|
||||
"bloodPressureSectionTitle": "Pressione arteriosa",
|
||||
"weightSectionTitle": "Peso",
|
||||
"pulseSectionTitle": "Polso",
|
||||
|
||||
@@ -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 />,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user