From b1a81bb65e9af1589e62c191d1e492d432081466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:57:15 +0200 Subject: [PATCH] refactor(insights): unify the learning-state gates behind one primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Glucose, sleep debt, chronotype, the trajectory forecast, and the composite-score anatomy each hand-rolled the warm 'still learning your X' paragraph — five copies with different markup, markers, and no shared accessibility contract. The calm voice held only because each author copied the tone by hand. Add a LearningGate primitive that owns the presentation and behaviour of a learning state (the muted body, an optional caveat line, the learning marker, and the polite live region) and repoint all five surfaces to it. Each keeps its own card chrome and its metric-specific copy; only the gate's look and behaviour are now shared, so they can no longer drift. --- .../insights/derived/score-anatomy-view.tsx | 15 +-- .../derived/trajectory-forecast-card.tsx | 32 +++---- .../glucose/glucose-clinical-panel.tsx | 26 +++--- .../insights/sleep/chronotype-card.tsx | 7 +- .../insights/sleep/sleep-debt-card.tsx | 7 +- .../ui/__tests__/learning-gate.test.tsx | 52 +++++++++++ src/components/ui/learning-gate.tsx | 92 +++++++++++++++++++ 7 files changed, 187 insertions(+), 44 deletions(-) create mode 100644 src/components/ui/__tests__/learning-gate.test.tsx create mode 100644 src/components/ui/learning-gate.tsx diff --git a/src/components/insights/derived/score-anatomy-view.tsx b/src/components/insights/derived/score-anatomy-view.tsx index 8a9e5e154..885f24d2c 100644 --- a/src/components/insights/derived/score-anatomy-view.tsx +++ b/src/components/insights/derived/score-anatomy-view.tsx @@ -4,6 +4,7 @@ import type { ReactNode } from "react"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; +import { LearningGate } from "@/components/ui/learning-gate"; import type { DerivedConfidence, DerivedCoverage, @@ -180,13 +181,13 @@ export function ScoreAnatomyView({ {insufficient ? ( -

- {insufficientNote ?? - t("insights.derived.anatomy.insufficient")} -

+ ) : (

diff --git a/src/components/insights/derived/trajectory-forecast-card.tsx b/src/components/insights/derived/trajectory-forecast-card.tsx index 99208be81..9a55f9f4a 100644 --- a/src/components/insights/derived/trajectory-forecast-card.tsx +++ b/src/components/insights/derived/trajectory-forecast-card.tsx @@ -6,6 +6,7 @@ import { TrendingUp, TrendingDown, Minus } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; +import { LearningGate } from "@/components/ui/learning-gate"; import { CoverageMeter } from "@/components/insights/derived/coverage-meter"; import { ProvenanceExplainer } from "@/components/insights/derived/provenance-explainer"; import { METRIC_PROVENANCE } from "@/components/insights/derived/standards"; @@ -237,25 +238,18 @@ export function TrajectoryForecastCard({ return (

-

- {t("insights.derived.trajectory.insufficient")} -

- {daysNeeded > 0 ? ( -

- {t("insights.derived.trajectory.daysNeeded", { - count: daysNeeded, - })} -

- ) : null} + 0 + ? t("insights.derived.trajectory.daysNeeded", { + count: daysNeeded, + }) + : undefined + } + />
diff --git a/src/components/insights/glucose/glucose-clinical-panel.tsx b/src/components/insights/glucose/glucose-clinical-panel.tsx index 6f70e07ae..144447eff 100644 --- a/src/components/insights/glucose/glucose-clinical-panel.tsx +++ b/src/components/insights/glucose/glucose-clinical-panel.tsx @@ -4,6 +4,7 @@ import { Activity, Droplet, TrendingUp } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { LearningGate } from "@/components/ui/learning-gate"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; @@ -99,18 +100,19 @@ export function GlucoseClinicalPanel() { {t("insights.bloodGlucose.clinical.learningTitle")} - -

- {hasAny - ? t("insights.bloodGlucose.clinical.learningBody", { - count: readingsLabel, - days: daysLabel, - }) - : t("insights.bloodGlucose.clinical.learningBodyEmpty")} -

-

- {t("insights.bloodGlucose.clinical.spotCaveat")} -

+ + ); diff --git a/src/components/insights/sleep/chronotype-card.tsx b/src/components/insights/sleep/chronotype-card.tsx index 280b0a544..1932e7b92 100644 --- a/src/components/insights/sleep/chronotype-card.tsx +++ b/src/components/insights/sleep/chronotype-card.tsx @@ -6,6 +6,7 @@ import { Clock, ChevronDown, ChevronUp } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; +import { LearningGate } from "@/components/ui/learning-gate"; import type { ChronotypeDto } from "./use-sleep-rhythm"; /** Minutes-of-day → "HH:MM" wall-clock label (24 h, zero-padded). */ @@ -48,12 +49,12 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) {
-

- {t("insights.sleep.chronotype.learning", { + + /> ); diff --git a/src/components/insights/sleep/sleep-debt-card.tsx b/src/components/insights/sleep/sleep-debt-card.tsx index 5558feb05..6a864d9ed 100644 --- a/src/components/insights/sleep/sleep-debt-card.tsx +++ b/src/components/insights/sleep/sleep-debt-card.tsx @@ -4,6 +4,7 @@ import { Moon } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { LearningGate } from "@/components/ui/learning-gate"; import type { SleepDebtDto } from "./use-sleep-rhythm"; /** Whole hours + minutes from a minute total, for the headline figure. */ @@ -36,12 +37,12 @@ export function SleepDebtCard({ debt }: { debt: SleepDebtDto }) { -

- {t("insights.sleep.debt.learning", { + + /> ); diff --git a/src/components/ui/__tests__/learning-gate.test.tsx b/src/components/ui/__tests__/learning-gate.test.tsx new file mode 100644 index 000000000..7a043e438 --- /dev/null +++ b/src/components/ui/__tests__/learning-gate.test.tsx @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { LearningGate } from "../learning-gate"; + +describe("", () => { + it("renders the message in a polite live region marked as learning", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Still learning your sleep."); + expect(html).toContain('role="status"'); + expect(html).toContain('aria-live="polite"'); + expect(html).toContain('data-state="learning"'); + expect(html).toContain('data-slot="learning-gate"'); + }); + + it("renders an optional caveat as a secondary line", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("About 5 more days needed."); + expect(html).toContain('data-slot="learning-gate-caveat"'); + }); + + it("omits the caveat line when none is given", () => { + const html = renderToStaticMarkup(); + expect(html).not.toContain("learning-gate-caveat"); + }); + + it("supports a per-surface body slot override for existing selectors", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('data-slot="score-anatomy-insufficient"'); + }); + + it("bordered variant adds the dashed standalone surface", () => { + const inline = renderToStaticMarkup(); + const bordered = renderToStaticMarkup( + , + ); + expect(inline).not.toContain("border-dashed"); + expect(bordered).toContain("border-dashed"); + }); +}); diff --git a/src/components/ui/learning-gate.tsx b/src/components/ui/learning-gate.tsx new file mode 100644 index 000000000..7befc3245 --- /dev/null +++ b/src/components/ui/learning-gate.tsx @@ -0,0 +1,92 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +/** + * v1.17.1 — the one "we're still learning your X" gate. + * + * Before this primitive, glucose, sleep-debt, chronotype, the trajectory + * forecast, and the composite-score anatomy each hand-rolled the warm + * "not enough data yet" paragraph — five copies with different markers, + * different `data-slot` values, and no shared a11y contract. The calm + * voice held only because each author copied the tone by hand, which is + * exactly the drift that bites the next metric author. + * + * `LearningGate` owns the *presentation and behaviour* of a learning + * state — the muted body paragraph, an optional secondary caveat line, + * the `data-state="learning"` marker, and the polite live region — while + * the caller passes the metric-specific localized copy and keeps its own + * card title / icon chrome. One look, one behaviour, copy still per-metric. + * + * Variants: + * - `"inline"` (default): a bare muted paragraph for use INSIDE a card + * that already has its own header (glucose / sleep / trajectory). + * - `"bordered"`: a dashed-border centered note for STANDALONE use where + * there is no surrounding card chrome (score anatomy). + */ +export interface LearningGateProps + extends Omit, "title"> { + /** Localized primary "still learning" sentence. */ + message: React.ReactNode; + /** Optional secondary line — a caveat or a "N more days" nudge. */ + caveat?: React.ReactNode; + variant?: "inline" | "bordered"; + /** Compact density for tiles / narrow rails. */ + compact?: boolean; + /** + * Optional override for the body `data-slot`, so existing per-surface + * test selectors (e.g. `glucose-learning-body`, + * `trajectory-insufficient`, `score-anatomy-insufficient`) keep + * resolving after the repoint. + */ + bodySlot?: string; +} + +export function LearningGate({ + message, + caveat, + variant = "inline", + compact = false, + bodySlot, + className, + ...props +}: LearningGateProps) { + const body = ( +

+ {message} +

+ ); + + return ( +
+ {body} + {caveat ? ( +

+ {caveat} +

+ ) : null} +
+ ); +}