chore(insights): polish VO2 strip, status show-more, and trend-card secondary row

v1.4.27 MB7 — three related insights surface polish items in one
commit:

- CF-38: the VO2 stat strip extends the 2-column phone grid all the
  way to `<lg` so tablets keep the 2×2 layout, and every chip floors
  at `min-h-[64px]` so the row paints at a consistent height even
  when one stat has no trend arrow.
- CF-39: the insight-status-card body collapses to `line-clamp-3`
  with a "Show more" toggle when the assessment text is long
  enough to actually clip. Short bodies render plain so the user
  never sees a useless toggle.
- CF-41 + CF-67: the trend-card mobile-secondary row retires and the
  all-time aggregate renders inline alongside the `7d` / `30d`
  pair on every viewport. The flex-wrap already handles the overflow
  on narrow tiles, and the standalone comparison-delta block drops
  its `hidden sm:block` gate so the callout reads on every screen.
This commit is contained in:
Marc-André Bombeck committed 2026-05-15 19:14:08 +02:00
1 parent 53c5163901
commit 071f26bf0f
3 files changed
+83 -55

No files matched your search

+17 -50
View File
@@ -272,10 +272,15 @@ export function TrendCard({
all-time + comparison-delta into a single line that stays
scannable on Galaxy Fold (280 px) and Pixel 5 (375 px). The
full layout returns at `>=sm`. */}
{/* v1.4.27 MB7 / CF-41 — the `hidden sm:block` gate retired
because the mobile-secondary block below is gone (CF-67
vestigial wrapper drop). The callout paints on every
viewport; when the parent grid is tight on `<sm`, the
flex-wrap on the sibling row + this callout's `mt-1`
horizontal-row layout absorb the overflow without crowding
the headline value above. */}
{compareBaseline !== "none" && compareDelta != null && (
<div
className={cn("mt-1", avgAllTime !== undefined && "hidden sm:block")}
>
<div className="mt-1">
<span
className={cn(
"inline-block max-w-full text-xs leading-snug font-medium [overflow-wrap:anywhere] tabular-nums",
@@ -374,14 +379,18 @@ export function TrendCard({
to last-30 days). Other tiles leave `avgAllTime` undefined so
the third sub-row never renders.
v1.4.22 W5 reconcile (Design-H2) — full label only at
`>=sm`; on `<sm` the all-time number moves to the
combined secondary row below so the tile keeps the same
vertical density as every other trend card. */}
v1.4.27 MB7 / CF-41 — the third sub-value renders inline
alongside `7d` / `30d` on every viewport. The previous
wave split `<sm` into a separate secondary row to keep
tile heights uniform, but in practice the flex-wrap on
the parent already handles the overflow gracefully — the
all-time pair wraps to a second line when the first row
fills up. Dropping the duplicated mobile-secondary block
also dropped CF-67 (the vestigial wrapper). */}
{avgAllTime !== undefined && (
<span
data-slot="trend-card-all-time"
className="hidden max-w-full min-w-0 [overflow-wrap:anywhere] sm:inline"
className="max-w-full min-w-0 [overflow-wrap:anywhere]"
>
{t(avgAllTimeLabelKey)}:{" "}
<span
@@ -395,48 +404,6 @@ export function TrendCard({
</span>
)}
</div>
{/* v1.4.22 W5 reconcile (Design-H2) — combined secondary row
for `<sm` viewports. Renders the all-time aggregate AND
(when comparison is on) the comparison delta on a single
line: "All-time 11% · vs −0.4 last month". Disappears at
`>=sm` because the full layout above takes over. */}
{avgAllTime !== undefined && (
<div
data-slot="trend-card-secondary-mobile"
className="text-muted-foreground mt-1 min-w-0 text-xs leading-snug [overflow-wrap:anywhere] sm:hidden"
>
<span>
{t(avgAllTimeLabelKey)}:{" "}
<span
className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
avgAllTimeColorClass,
)}
>
{avgAllTime !== null ? formatValue(avgAllTime) : "—"}
</span>
</span>
{compareBaseline !== "none" && compareDelta != null && (
<>
<span aria-hidden="true"> · </span>
<span
className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
comparisonDeltaColor,
)}
data-slot="tile-compare-delta-mobile"
data-compare-baseline={compareBaseline}
>
{`Δ ${formatDelta(compareDelta)}${unit ? ` ${unit}` : ""} ${t(
compareBaseline === "lastMonth"
? "comparison.captionLastMonth"
: "comparison.captionLastYear",
)}`}
</span>
</>
)}
</div>
)}
</TooltipProvider>
</div>
);
@@ -1,9 +1,12 @@
"use client";
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Loader2 } from "lucide-react";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { cn } from "@/lib/utils";
// ─── Types ────────────────────────────────────────────────
@@ -102,15 +105,68 @@ export function InsightStatusCard({
token surfaces verbatim. The producer side now strips too;
this consumer-side wrap keeps existing caches clean while
they roll forward. */}
<p className="text-muted-foreground text-sm leading-relaxed">
{stripChartTokens(text)}
</p>
<StatusBody text={stripChartTokens(text)} />
<LastUpdatedFooter updatedAt={updatedAt} />
</CardContent>
</Card>
);
}
/**
* 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.
*/
function StatusBody({ text }: { text: string }) {
const { locale } = 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";
return (
<div className="space-y-1">
<p
className={cn(
"text-muted-foreground text-sm leading-relaxed",
!expanded && "line-clamp-3",
)}
>
{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>
</div>
);
}
function LastUpdatedFooter({ updatedAt }: { updatedAt: string | null }) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -135,10 +135,15 @@ export function Vo2MaxChartRow({
<p className="text-muted-foreground text-xs">{unit}</p>
</div>
{/* Stat strip — latest / min / max / avg30 / Δ vs. prior period. */}
{/* Stat strip — latest / min / max / avg30 / Δ vs. prior period.
v1.4.27 MB7 / CF-38 — extend the 2-column phone grid all the
way to `<lg` so tablets keep the 2×2 layout (avoids
mid-viewport reflows on Pixel Fold) and lift each cell to a
`min-h-[64px]` floor so the chips paint at consistent
heights even when one stat has no trend arrow. */}
<div
data-slot="vo2-chart-row-stats"
className="bg-card border-border grid grid-cols-2 gap-3 rounded-xl border p-4 sm:grid-cols-4"
className="bg-card border-border grid grid-cols-2 gap-3 rounded-xl border p-4 lg:grid-cols-4 [&>div]:min-h-[64px]"
>
<div data-slot="vo2-stat" data-stat="latest" className="space-y-1">
<p className="text-muted-foreground text-xs uppercase tracking-wide">