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} +
+ ); +}