mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
refactor(insights): unify the learning-state gates behind one primitive
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.
This commit is contained in:
1 parent
fd5cc78377
commit
b1a81bb65e
7 files changed
+187
-44
No files matched your search
@@ -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({
|
||||
</div>
|
||||
|
||||
{insufficient ? (
|
||||
<p
|
||||
data-slot="score-anatomy-insufficient"
|
||||
className="text-muted-foreground rounded-lg border border-dashed px-4 py-6 text-center text-xs"
|
||||
>
|
||||
{insufficientNote ??
|
||||
t("insights.derived.anatomy.insufficient")}
|
||||
</p>
|
||||
<LearningGate
|
||||
variant="bordered"
|
||||
bodySlot="score-anatomy-insufficient"
|
||||
message={
|
||||
insufficientNote ?? t("insights.derived.anatomy.insufficient")
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div data-slot="score-anatomy-contributors" className="space-y-3">
|
||||
<p className="text-muted-foreground text-[11px] font-semibold tracking-wide uppercase">
|
||||
|
||||
@@ -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 (
|
||||
<div className={className}>
|
||||
<CardShell provenance={data.provenance} compact={compact}>
|
||||
<p
|
||||
className={cn(
|
||||
"text-muted-foreground",
|
||||
compact ? "text-xs leading-snug" : "text-sm",
|
||||
)}
|
||||
data-slot="trajectory-insufficient"
|
||||
>
|
||||
{t("insights.derived.trajectory.insufficient")}
|
||||
</p>
|
||||
{daysNeeded > 0 ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs leading-snug"
|
||||
data-slot="trajectory-days-needed"
|
||||
>
|
||||
{t("insights.derived.trajectory.daysNeeded", {
|
||||
count: daysNeeded,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
<LearningGate
|
||||
compact={compact}
|
||||
bodySlot="trajectory-insufficient"
|
||||
message={t("insights.derived.trajectory.insufficient")}
|
||||
caveat={
|
||||
daysNeeded > 0
|
||||
? t("insights.derived.trajectory.daysNeeded", {
|
||||
count: daysNeeded,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<CoverageMeter coverage={data.coverage} />
|
||||
</CardShell>
|
||||
</div>
|
||||
|
||||
@@ -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")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-muted-foreground text-sm" data-slot="glucose-learning-body">
|
||||
{hasAny
|
||||
? t("insights.bloodGlucose.clinical.learningBody", {
|
||||
count: readingsLabel,
|
||||
days: daysLabel,
|
||||
})
|
||||
: t("insights.bloodGlucose.clinical.learningBodyEmpty")}
|
||||
</p>
|
||||
<p className="text-muted-foreground/80 text-xs">
|
||||
{t("insights.bloodGlucose.clinical.spotCaveat")}
|
||||
</p>
|
||||
<CardContent>
|
||||
<LearningGate
|
||||
bodySlot="glucose-learning-body"
|
||||
message={
|
||||
hasAny
|
||||
? t("insights.bloodGlucose.clinical.learningBody", {
|
||||
count: readingsLabel,
|
||||
days: daysLabel,
|
||||
})
|
||||
: t("insights.bloodGlucose.clinical.learningBodyEmpty")
|
||||
}
|
||||
caveat={t("insights.bloodGlucose.clinical.spotCaveat")}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -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 }) {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.sleep.chronotype.learning", {
|
||||
<LearningGate
|
||||
message={t("insights.sleep.chronotype.learning", {
|
||||
counted: chronotype.freeNightsCounted,
|
||||
total: need,
|
||||
})}
|
||||
</p>
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -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 }) {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.sleep.debt.learning", {
|
||||
<LearningGate
|
||||
message={t("insights.sleep.debt.learning", {
|
||||
counted: debt.nightsCounted,
|
||||
remaining: debt.nightsUntilReady,
|
||||
})}
|
||||
</p>
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { LearningGate } from "../learning-gate";
|
||||
|
||||
describe("<LearningGate>", () => {
|
||||
it("renders the message in a polite live region marked as learning", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<LearningGate message="Still learning your sleep." />,
|
||||
);
|
||||
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(
|
||||
<LearningGate
|
||||
message="Not enough data yet."
|
||||
caveat="About 5 more days needed."
|
||||
/>,
|
||||
);
|
||||
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(<LearningGate message="Learning." />);
|
||||
expect(html).not.toContain("learning-gate-caveat");
|
||||
});
|
||||
|
||||
it("supports a per-surface body slot override for existing selectors", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<LearningGate
|
||||
message="Not enough to score."
|
||||
bodySlot="score-anatomy-insufficient"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="score-anatomy-insufficient"');
|
||||
});
|
||||
|
||||
it("bordered variant adds the dashed standalone surface", () => {
|
||||
const inline = renderToStaticMarkup(<LearningGate message="x" />);
|
||||
const bordered = renderToStaticMarkup(
|
||||
<LearningGate message="x" variant="bordered" />,
|
||||
);
|
||||
expect(inline).not.toContain("border-dashed");
|
||||
expect(bordered).toContain("border-dashed");
|
||||
});
|
||||
});
|
||||
@@ -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<React.HTMLAttributes<HTMLDivElement>, "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 = (
|
||||
<p
|
||||
data-slot={bodySlot ?? "learning-gate-body"}
|
||||
className={cn(
|
||||
"text-muted-foreground",
|
||||
compact ? "text-xs leading-snug" : "text-sm",
|
||||
variant === "bordered" && "text-center text-xs",
|
||||
)}
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="learning-gate"
|
||||
data-state="learning"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
"space-y-2",
|
||||
variant === "bordered" &&
|
||||
"rounded-lg border border-dashed px-4 py-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
{caveat ? (
|
||||
<p
|
||||
data-slot="learning-gate-caveat"
|
||||
className="text-muted-foreground/80 text-xs leading-snug"
|
||||
>
|
||||
{caveat}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user