From 8d36e69f7509f6b0c9ff0cad24e130c2b2e5def8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 5 Jul 2026 17:42:54 +0200 Subject: [PATCH] refactor(ui): migrate hand-rolled tile headers onto TileHeader Nine insights/cycle cards hand-rolled their icon+title rows at drifting sizes and colours: the sleep trio (debt, chronotype, average) and the glucose clinical panel led with h-4 accent icons, the cycle module (predictions, BBT chart, symptom patterns) coloured its header icons text-primary against both header contracts, and the hypnogram, correlation card, and mood correlation cards carried bespoke title rows. All of them now compose TileHeader. TileHeader grows the two documented gaps instead of a fourth header language: an optional icon (text-only tile titles) and the ONE sanctioned compact variant size="sm" (icon h-4 w-4, title text-sm) used by the dense correlation tiles. The sleep-rhythm guard test now pins the tile-header slot instead of the retired accent icon colour. --- src/components/cycle/bbt-chart.tsx | 8 +-- .../cycle/cycle-symptom-patterns.tsx | 11 ++-- src/components/cycle/predictions-panel.tsx | 6 +- src/components/insights/correlation-card.tsx | 40 ++++++++------ .../glucose/glucose-clinical-panel.tsx | 19 ++++--- .../insights/mood/mood-correlation-cards.tsx | 48 ++++++++-------- src/components/insights/sleep-hypnogram.tsx | 7 +-- .../__tests__/sleep-rhythm-cards.test.tsx | 11 ++-- .../insights/sleep/average-sleep-card.tsx | 23 ++++---- .../insights/sleep/chronotype-card.tsx | 20 +++---- .../insights/sleep/sleep-debt-card.tsx | 55 ++++++++++--------- src/components/insights/tile-header.tsx | 30 ++++++++-- 12 files changed, 149 insertions(+), 129 deletions(-) diff --git a/src/components/cycle/bbt-chart.tsx b/src/components/cycle/bbt-chart.tsx index ed0555b4d..4f7f81656 100644 --- a/src/components/cycle/bbt-chart.tsx +++ b/src/components/cycle/bbt-chart.tsx @@ -35,7 +35,8 @@ import { YAxis, } from "recharts"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { cn } from "@/lib/utils"; @@ -150,10 +151,7 @@ export function BbtChart({ return ( - - + {!hasCurve ? ( diff --git a/src/components/cycle/cycle-symptom-patterns.tsx b/src/components/cycle/cycle-symptom-patterns.tsx index 84a4e2a2a..a9d0ee94f 100644 --- a/src/components/cycle/cycle-symptom-patterns.tsx +++ b/src/components/cycle/cycle-symptom-patterns.tsx @@ -3,7 +3,8 @@ import { useMemo } from "react"; import { Activity } from "lucide-react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; import { useTranslations } from "@/lib/i18n/context"; import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog"; import { PHASE_HUE } from "./phase-tokens"; @@ -56,10 +57,10 @@ export function CycleSymptomPatterns({ rows }: CycleSymptomPatternsProps) { return ( - - + {rows.length === 0 ? ( diff --git a/src/components/cycle/predictions-panel.tsx b/src/components/cycle/predictions-panel.tsx index cb24978a5..f7175c2c8 100644 --- a/src/components/cycle/predictions-panel.tsx +++ b/src/components/cycle/predictions-panel.tsx @@ -10,6 +10,7 @@ import { CardTitle, } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; +import { TileHeader } from "@/components/insights/tile-header"; import { useTranslations } from "@/lib/i18n/context"; import { FERTILE_HUE, FLOW_HUE, OVULATION_HUE } from "./phase-tokens"; import type { CyclePrediction, CycleHistoryResponse } from "./types"; @@ -64,10 +65,7 @@ export function PredictionsPanel({
- - + {rawChartMode ? ( diff --git a/src/components/insights/correlation-card.tsx b/src/components/insights/correlation-card.tsx index e79a44e58..8229a2e47 100644 --- a/src/components/insights/correlation-card.tsx +++ b/src/components/insights/correlation-card.tsx @@ -4,7 +4,8 @@ import dynamic from "next/dynamic"; import { Activity, Sparkles } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; import { EmptyState } from "@/components/ui/empty-state"; import { Skeleton } from "@/components/ui/skeleton"; import { useTranslations } from "@/lib/i18n/context"; @@ -116,23 +117,26 @@ export function CorrelationCard({ result }: CorrelationCardProps) { )} /> -
-
- {title} -

{subtitle}

-
- {result.status === "ok" && ( - - {t(CONFIDENCE_LABEL_KEY[result.confidenceBand.label])} - - )} +
+ + {t(CONFIDENCE_LABEL_KEY[result.confidenceBand.label])} + + ) : undefined + } + /> +

{subtitle}

diff --git a/src/components/insights/glucose/glucose-clinical-panel.tsx b/src/components/insights/glucose/glucose-clinical-panel.tsx index 4958f36d1..6a0214972 100644 --- a/src/components/insights/glucose/glucose-clinical-panel.tsx +++ b/src/components/insights/glucose/glucose-clinical-panel.tsx @@ -4,7 +4,8 @@ import Link from "next/link"; import { Activity, Droplet, FlaskConical, TrendingUp } from "lucide-react"; import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; import { InfoHint } from "@/components/ui/info-hint"; import { LearningGate } from "@/components/ui/learning-gate"; import { LearnMoreLink } from "@/components/ui/learn-more-link"; @@ -97,10 +98,10 @@ export function GlucoseClinicalPanel() { return ( - - +
- - + {t("insights.bloodGlucose.clinical.subtitle", { days: Math.max(1, Math.round(clinical.actualSpanDays)), diff --git a/src/components/insights/mood/mood-correlation-cards.tsx b/src/components/insights/mood/mood-correlation-cards.tsx index 187ec34cf..4efb3f351 100644 --- a/src/components/insights/mood/mood-correlation-cards.tsx +++ b/src/components/insights/mood/mood-correlation-cards.tsx @@ -4,7 +4,8 @@ import dynamic from "next/dynamic"; import { Activity } from "lucide-react"; import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; import { EmptyState } from "@/components/ui/empty-state"; import { Skeleton } from "@/components/ui/skeleton"; import { useTranslations } from "@/lib/i18n/context"; @@ -99,28 +100,29 @@ function MoodCorrelationCard({ className={className} > -
- - {t(TITLE_KEY[kind])} - - {hasResult && data.result && ( -
- - {t(STRENGTH_KEY[data.result.strength])} - - {/* v1.12.4 (C4) — the "{n} paired days · r" line sat under the - scatter as a half-empty row. Move it into an explainer icon - in the header so the card footprint stays tight. */} - -
- )} -
+ + + {t(STRENGTH_KEY[data.result.strength])} + + {/* v1.12.4 (C4) — the "{n} paired days · r" line sat under the + scatter as a half-empty row. Move it into an explainer icon + in the header so the card footprint stays tight. */} + +
+ ) : undefined + } + />
{hasResult && data.result ? ( diff --git a/src/components/insights/sleep-hypnogram.tsx b/src/components/insights/sleep-hypnogram.tsx index 29106ce2f..fed7b4ec0 100644 --- a/src/components/insights/sleep-hypnogram.tsx +++ b/src/components/insights/sleep-hypnogram.tsx @@ -10,7 +10,8 @@ import { YAxis, } from "recharts"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context"; import { hourCycleOptions } from "@/lib/format-locale"; import { STAGE_COLORS } from "./sleep-stage-stacked-bar"; @@ -249,9 +250,7 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
- - {t("insights.sleep.hypnogram.title")} - + {t("insights.sleep.hypnogram.subtitle", { asleep: formatMinutes(session.asleepMinutes, locale), diff --git a/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx b/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx index 8638beae8..282214646 100644 --- a/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx +++ b/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx @@ -80,8 +80,10 @@ describe("", () => { for (const html of [partial, ready]) { expect(html).not.toContain("gap-2 py-4 md:gap-2 md:py-4"); expect(html).toContain("md:py-6"); - // Semantic info token, not the raw Dracula cyan. - expect(html).toContain("text-info"); + // Header rides the canonical TileHeader (foreground icon), never a + // hand-rolled accent-coloured row or the raw Dracula cyan. + expect(html).toContain('data-slot="tile-header"'); + expect(html).not.toContain("text-info"); expect(html).not.toContain("text-dracula-cyan"); } }); @@ -123,11 +125,12 @@ describe("", () => { expect(html).toContain("text-2xl"); }); - it("uses the semantic info token, never the raw Dracula cyan", () => { + it("renders the canonical TileHeader, never the raw Dracula cyan", () => { const learning = render(); const ready = render(); for (const html of [learning, ready]) { - expect(html).toContain("text-info"); + expect(html).toContain('data-slot="tile-header"'); + expect(html).not.toContain("text-info"); expect(html).not.toContain("text-dracula-cyan"); } }); diff --git a/src/components/insights/sleep/average-sleep-card.tsx b/src/components/insights/sleep/average-sleep-card.tsx index 8093f3c51..2071624e0 100644 --- a/src/components/insights/sleep/average-sleep-card.tsx +++ b/src/components/insights/sleep/average-sleep-card.tsx @@ -3,8 +3,9 @@ import { BedDouble } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { LearningGate } from "@/components/ui/learning-gate"; +import { TileHeader } from "@/components/insights/tile-header"; import type { AverageSleepDto } from "./use-sleep-rhythm"; /** Whole hours + minutes from a minute total, for the headline figure. */ @@ -35,12 +36,10 @@ export function AverageSleepCard({ average }: { average: AverageSleepDto }) { return ( -
- - - {t("insights.sleep.average.title")} - -
+
-
- - - {t("insights.sleep.average.title")} - -
+
diff --git a/src/components/insights/sleep/chronotype-card.tsx b/src/components/insights/sleep/chronotype-card.tsx index 7f805a4c1..14c0e66be 100644 --- a/src/components/insights/sleep/chronotype-card.tsx +++ b/src/components/insights/sleep/chronotype-card.tsx @@ -4,8 +4,9 @@ import { useState } from "react"; import { Clock, ChevronDown, ChevronUp } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { LearningGate } from "@/components/ui/learning-gate"; +import { TileHeader } from "@/components/insights/tile-header"; import type { ChronotypeDto } from "./use-sleep-rhythm"; /** Minutes-of-day → "HH:MM" wall-clock label (24 h, zero-padded). */ @@ -48,12 +49,10 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) { return ( -
- - - {t("insights.sleep.chronotype.title")} - -
+
-
- - - {t("insights.sleep.chronotype.title")} - -
+
{/* Uniform with the sleep-debt tile: a bold primary value (the band) at diff --git a/src/components/insights/sleep/sleep-debt-card.tsx b/src/components/insights/sleep/sleep-debt-card.tsx index 66989458b..8799ad0e2 100644 --- a/src/components/insights/sleep/sleep-debt-card.tsx +++ b/src/components/insights/sleep/sleep-debt-card.tsx @@ -3,8 +3,9 @@ import { Moon } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { InfoHint } from "@/components/ui/info-hint"; +import { TileHeader } from "@/components/insights/tile-header"; import { LearningGate } from "@/components/ui/learning-gate"; import type { SleepDebtDto } from "./use-sleep-rhythm"; @@ -33,19 +34,19 @@ export function SleepDebtCard({ debt }: { debt: SleepDebtDto }) { return ( -
- - - {t("insights.sleep.debt.title")} - - {/* v1.25.0 — when the active source on the user's sleep-debt ladder - is our own COMPUTED engine (every user today, until a provider - ships a native debt), explain what the figure means and that it - differs from a wearable's native number. */} - {debt.source === "COMPUTED" ? ( - - ) : null} -
+ {/* v1.25.0 — when the active source on the user's sleep-debt ladder + is our own COMPUTED engine (every user today, until a provider + ships a native debt), explain what the figure means and that it + differs from a wearable's native number. */} + + ) : undefined + } + />
-
- - - {t("insights.sleep.debt.title")} - - {/* v1.25.0 — the resolved full-debt card is the common case and the - place the figure most needs context: explain the COMPUTED engine - and that it differs from a wearable's native number here too, not - only in the still-learning `partial` state above. */} - {debt.source === "COMPUTED" ? ( - - ) : null} -
+ {/* v1.25.0 — the resolved full-debt card is the common case and the + place the figure most needs context: explain the COMPUTED engine + and that it differs from a wearable's native number here too, not + only in the still-learning `partial` state above. */} + + ) : undefined + } + />
diff --git a/src/components/insights/tile-header.tsx b/src/components/insights/tile-header.tsx index 5d0428e64..e31c032ed 100644 --- a/src/components/insights/tile-header.tsx +++ b/src/components/insights/tile-header.tsx @@ -30,13 +30,23 @@ interface TileHeaderProps { * Leading glyph. Accepts a Lucide icon component (or any component that * takes a `className`) so the caller passes the component itself * (`icon={Target}`), not a pre-sized node — `TileHeader` owns the size - * and colour so every tile header matches. + * and colour so every tile header matches. Optional: a text-only tile + * header (chart cards whose title needs no glyph) omits it and still + * gets the canonical row + `CardTitle` treatment. */ - icon: ComponentType<{ className?: string }>; + icon?: ComponentType<{ className?: string }>; /** Heading text (or node). Rendered inside a `CardTitle` at `text-base`. */ title: ReactNode; /** Optional trailing affordance pinned to the right edge of the row. */ right?: ReactNode; + /** + * The ONE sanctioned compact variant: icon `h-4 w-4`, title `text-sm` — + * for dense correlation/stat tiles that sit several to a row where the + * full `text-base` header visually overpowers the tile body. Everything + * else stays on the default; do not express a third size via + * `titleClassName`. + */ + size?: "default" | "sm"; className?: string; titleClassName?: string; /** Optional id on the `CardTitle`, e.g. for an `aria-labelledby` link. */ @@ -47,6 +57,7 @@ export function TileHeader({ icon: Icon, title, right, + size = "default", className, titleClassName, id, @@ -58,8 +69,19 @@ export function TileHeader({ > {/* Icon and heading share the foreground colour — the calm, high-contrast read the "Einschätzung" card established. */} -