mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(insights): elevate coincident-deviation to a dedicated "Today's signal" card
Promote the COINCIDENT_DEVIATION flag from a fire-only tile buried in the below-the-fold vitals grid to an always-mounted headline card at the top of the Insights overview. The card renders four calm states off the single existing derived payload — building-baselines, all-clear, watch, fired — with no new engine math, route, or schema change. Keep it calm and clinically safe: at most an amber band, never red, no score, no chart, no push. Soften a fired flag to the watch tone when the backing history is thin. The fired/watch states carry the load-bearing "possible factors, never a cause / not a diagnosis" framing and reuse ProvenanceExplainer + CoverageMeter + the band tokens verbatim. Remove the now-redundant tile, its batch token, and the hasRenderableVital branch from the vitals grid so the signal is not shown twice.
This commit is contained in:
1 parent
fe099acd67
commit
789ec60d97
11 files changed
+562
-102
No files matched your search
+9
-1
@@ -2632,7 +2632,15 @@
|
||||
"coincident": {
|
||||
"label": "Mehrere Vitalwerte außerhalb der Baseline",
|
||||
"summary": "{count} deiner Vitalwerte lagen heute außerhalb ihres persönlichen Bereichs.",
|
||||
"vitals": "Außerhalb des Bereichs: {list}"
|
||||
"vitals": "Außerhalb des Bereichs: {list}",
|
||||
"cardTitle": "Signal des Tages",
|
||||
"allClear": "Alle deine Vitalwerte liegen heute in ihrem persönlichen Bereich.",
|
||||
"allClearMeta": "{count} deiner Vitalwerte gegen deinen eigenen typischen Bereich geprüft.",
|
||||
"watch": "Ein Vitalwert liegt heute außerhalb seines üblichen Bereichs.",
|
||||
"watchVital": "Außerhalb des Bereichs: {vital}. Oft nur einmalig — behalte es in den nächsten Tagen im Blick.",
|
||||
"firedHeadline": "{count} deiner Vitalwerte liegen heute außerhalb ihres persönlichen Bereichs.",
|
||||
"factors": "Mögliche Faktoren — nie eine Ursache: ein hartes Training, schlechter Schlaf, Alkohol, Höhe, Stress oder Krankheit. Das ist ein Hinweis aus deinen eigenen Baselines, keine Diagnose.",
|
||||
"building": "Deine persönlichen Baselines entstehen — erfasse ein paar Tage mehr, dann erscheint dies."
|
||||
}
|
||||
},
|
||||
"cardioFitness": {
|
||||
|
||||
+9
-1
@@ -2632,7 +2632,15 @@
|
||||
"coincident": {
|
||||
"label": "Several vitals off baseline",
|
||||
"summary": "{count} of your vitals sat outside their personal range today.",
|
||||
"vitals": "Outside range: {list}"
|
||||
"vitals": "Outside range: {list}",
|
||||
"cardTitle": "Today's signal",
|
||||
"allClear": "All your vitals are within their personal range today.",
|
||||
"allClearMeta": "Checked {count} of your vitals against your own typical range.",
|
||||
"watch": "One vital is outside its usual range today.",
|
||||
"watchVital": "Outside range: {vital}. Often just a one-off — worth a glance over the next few days.",
|
||||
"firedHeadline": "{count} of your vitals are outside their personal range today.",
|
||||
"factors": "Possible factors — never a cause: a hard workout, poor sleep, alcohol, altitude, stress, or illness. This is awareness from your own baselines, not a diagnosis.",
|
||||
"building": "Building your personal baselines — track a few more days and this will appear."
|
||||
}
|
||||
},
|
||||
"cardioFitness": {
|
||||
|
||||
+9
-1
@@ -2632,7 +2632,15 @@
|
||||
"coincident": {
|
||||
"label": "Varias constantes fuera de la base",
|
||||
"summary": "Hoy, {count} de tus constantes quedaron fuera de su rango personal.",
|
||||
"vitals": "Fuera de rango: {list}"
|
||||
"vitals": "Fuera de rango: {list}",
|
||||
"cardTitle": "Señal de hoy",
|
||||
"allClear": "Hoy todas tus constantes están dentro de su rango personal.",
|
||||
"allClearMeta": "Se revisaron {count} de tus constantes frente a tu propio rango habitual.",
|
||||
"watch": "Hoy una constante está fuera de su rango habitual.",
|
||||
"watchVital": "Fuera de rango: {vital}. A menudo es algo puntual; conviene vigilarlo los próximos días.",
|
||||
"firedHeadline": "Hoy, {count} de tus constantes están fuera de su rango personal.",
|
||||
"factors": "Posibles factores, nunca una causa: un entrenamiento intenso, dormir mal, alcohol, altitud, estrés o una enfermedad. Es un aviso a partir de tus propias bases, no un diagnóstico.",
|
||||
"building": "Construyendo tus bases personales: registra unos días más y esto aparecerá."
|
||||
}
|
||||
},
|
||||
"cardioFitness": {
|
||||
|
||||
+9
-1
@@ -2632,7 +2632,15 @@
|
||||
"coincident": {
|
||||
"label": "Plusieurs constantes hors référence",
|
||||
"summary": "Aujourd’hui, {count} de vos constantes sont sorties de leur plage personnelle.",
|
||||
"vitals": "Hors plage : {list}"
|
||||
"vitals": "Hors plage : {list}",
|
||||
"cardTitle": "Signal du jour",
|
||||
"allClear": "Aujourd’hui, toutes vos constantes sont dans leur plage personnelle.",
|
||||
"allClearMeta": "{count} de vos constantes vérifiées par rapport à votre propre plage habituelle.",
|
||||
"watch": "Aujourd’hui, une constante est hors de sa plage habituelle.",
|
||||
"watchVital": "Hors plage : {vital}. Souvent ponctuel — à surveiller ces prochains jours.",
|
||||
"firedHeadline": "Aujourd’hui, {count} de vos constantes sont hors de leur plage personnelle.",
|
||||
"factors": "Facteurs possibles — jamais une cause : un entraînement intense, un mauvais sommeil, l’alcool, l’altitude, le stress ou une maladie. C’est un repère issu de vos propres références, pas un diagnostic.",
|
||||
"building": "Construction de vos références personnelles — enregistrez quelques jours de plus et cela apparaîtra."
|
||||
}
|
||||
},
|
||||
"cardioFitness": {
|
||||
|
||||
+9
-1
@@ -2632,7 +2632,15 @@
|
||||
"coincident": {
|
||||
"label": "Più parametri fuori dalla base",
|
||||
"summary": "Oggi {count} dei tuoi parametri vitali sono usciti dal loro intervallo personale.",
|
||||
"vitals": "Fuori intervallo: {list}"
|
||||
"vitals": "Fuori intervallo: {list}",
|
||||
"cardTitle": "Segnale di oggi",
|
||||
"allClear": "Oggi tutti i tuoi parametri vitali sono nel loro intervallo personale.",
|
||||
"allClearMeta": "Controllati {count} dei tuoi parametri rispetto al tuo intervallo abituale.",
|
||||
"watch": "Oggi un parametro è fuori dal suo intervallo abituale.",
|
||||
"watchVital": "Fuori intervallo: {vital}. Spesso è un episodio isolato — tienilo d’occhio nei prossimi giorni.",
|
||||
"firedHeadline": "Oggi {count} dei tuoi parametri vitali sono fuori dal loro intervallo personale.",
|
||||
"factors": "Possibili fattori — mai una causa: un allenamento intenso, poco sonno, alcol, altitudine, stress o una malattia. È un avviso basato sui tuoi parametri, non una diagnosi.",
|
||||
"building": "Stiamo costruendo i tuoi riferimenti personali — registra ancora qualche giorno e comparirà."
|
||||
}
|
||||
},
|
||||
"cardioFitness": {
|
||||
|
||||
+9
-1
@@ -2632,7 +2632,15 @@
|
||||
"coincident": {
|
||||
"label": "Kilka parametrów poza bazą",
|
||||
"summary": "Dziś {count} Twoich parametrów życiowych znalazło się poza osobistym zakresem.",
|
||||
"vitals": "Poza zakresem: {list}"
|
||||
"vitals": "Poza zakresem: {list}",
|
||||
"cardTitle": "Sygnał dnia",
|
||||
"allClear": "Dziś wszystkie Twoje parametry życiowe mieszczą się w osobistym zakresie.",
|
||||
"allClearMeta": "Sprawdzono {count} Twoich parametrów względem Twojego typowego zakresu.",
|
||||
"watch": "Dziś jeden parametr jest poza swoim zwykłym zakresem.",
|
||||
"watchVital": "Poza zakresem: {vital}. Często to jednorazowy odczyt — warto obserwować przez kilka dni.",
|
||||
"firedHeadline": "Dziś {count} Twoich parametrów życiowych jest poza osobistym zakresem.",
|
||||
"factors": "Możliwe czynniki — nigdy przyczyna: intensywny trening, słaby sen, alkohol, wysokość, stres lub choroba. To wskazówka z Twoich własnych baz, nie diagnoza.",
|
||||
"building": "Budujemy Twoje osobiste bazy — zarejestruj jeszcze kilka dni, a to się pojawi."
|
||||
}
|
||||
},
|
||||
"cardioFitness": {
|
||||
|
||||
@@ -97,6 +97,24 @@ const RhythmEventsCard = dynamic(
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
// v1.10.3 — "Today's signal" headline card. Promotes COINCIDENT_DEVIATION from
|
||||
// a buried below-the-fold tile to the top-of-overview daily read (the
|
||||
// always-present Apple/WHOOP/Oura pattern). Deferred behind `next/dynamic`; it
|
||||
// owns its own derived-metric query and renders four calm states. A
|
||||
// fixed-height skeleton matches the resolved card so the top of the page does
|
||||
// not shift while the chunk + data resolve.
|
||||
const CoincidentDeviationCard = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/coincident-deviation-card").then((mod) => ({
|
||||
default: mod.CoincidentDeviationCard,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="bg-card border-border h-[7.5rem] animate-pulse rounded-xl border motion-reduce:animate-none" />
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* v1.4.25 W4d — Insights mother page.
|
||||
@@ -246,6 +264,8 @@ export default function InsightsPage() {
|
||||
healthScore={analytics?.healthScore ?? undefined}
|
||||
/>
|
||||
|
||||
<CoincidentDeviationCard enabled={isAuthenticated} />
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { DerivedMetricResponse } from "@/components/insights/derived/use-derived-metric";
|
||||
import type { CoincidentDeviationValue } from "@/lib/insights/derived/coincident-deviation";
|
||||
|
||||
// Drive the card by stubbing the single derived-metric hook it reads.
|
||||
const useDerivedMetric = vi.fn();
|
||||
vi.mock("@/components/insights/derived/use-derived-metric", () => ({
|
||||
useDerivedMetric: (...a: unknown[]) => useDerivedMetric(...a),
|
||||
}));
|
||||
// The provenance explainer pulls a mobile flag + the i18n context; render it
|
||||
// in its desktop (Popover) form for the static markup so its trigger is in the
|
||||
// tree. The card test only asserts the trigger is present, not its open state.
|
||||
vi.mock("@/hooks/use-is-mobile", () => ({ useIsMobile: () => false }));
|
||||
|
||||
import { CoincidentDeviationCard } from "../coincident-deviation-card";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
type Resp = DerivedMetricResponse<CoincidentDeviationValue>;
|
||||
|
||||
function deviation(
|
||||
type: string,
|
||||
outside: boolean,
|
||||
): CoincidentDeviationValue["vitals"][number] {
|
||||
return {
|
||||
type: type as never,
|
||||
value: outside ? 70 : 55,
|
||||
center: 55,
|
||||
low: 48,
|
||||
high: 62,
|
||||
outside,
|
||||
direction: outside ? "above" : "in",
|
||||
};
|
||||
}
|
||||
|
||||
function ok(
|
||||
value: CoincidentDeviationValue,
|
||||
band: "high" | "medium" | "low" | "draft" = "high",
|
||||
): Resp {
|
||||
return {
|
||||
metric: "COINCIDENT_DEVIATION",
|
||||
status: "ok",
|
||||
value,
|
||||
coverage: { requiredInputs: 5, presentInputs: 5, historyDays: 30, missing: [] },
|
||||
confidence: { score: 90, band },
|
||||
provenance: {
|
||||
inputs: ["RESTING_HEART_RATE", "RESPIRATORY_RATE"],
|
||||
source: "DAY",
|
||||
windowDays: 30,
|
||||
computedAt: "2026-06-03T00:00:00.000Z",
|
||||
},
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
|
||||
function insufficient(): Resp {
|
||||
return {
|
||||
metric: "COINCIDENT_DEVIATION",
|
||||
status: "insufficient",
|
||||
value: null,
|
||||
coverage: { requiredInputs: 2, presentInputs: 1, historyDays: 0, missing: [] },
|
||||
confidence: null,
|
||||
provenance: {
|
||||
inputs: ["RESTING_HEART_RATE"],
|
||||
source: "live",
|
||||
windowDays: 30,
|
||||
computedAt: "2026-06-03T00:00:00.000Z",
|
||||
},
|
||||
reason: "too_few_banded_vitals",
|
||||
};
|
||||
}
|
||||
|
||||
function mock(data: Resp | undefined) {
|
||||
useDerivedMetric.mockReturnValue({ data });
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("<CoincidentDeviationCard>", () => {
|
||||
it("renders a CLS-safe skeleton while the read is in flight", () => {
|
||||
mock(undefined);
|
||||
const html = render(<CoincidentDeviationCard />);
|
||||
expect(html).toContain('data-slot="coincident-deviation-card-skeleton"');
|
||||
expect(html).not.toContain('data-slot="coincident-deviation-card"');
|
||||
});
|
||||
|
||||
it("renders the insufficient (building baselines) state, never an alarm", () => {
|
||||
mock(insufficient());
|
||||
const html = render(<CoincidentDeviationCard />);
|
||||
expect(html).toContain('data-state="insufficient"');
|
||||
expect(html).toContain('data-slot="coincident-building"');
|
||||
expect(html).toContain('data-slot="coverage-meter"');
|
||||
// Calm, not amber/red.
|
||||
expect(html).not.toContain("border-warning");
|
||||
expect(html).not.toContain("text-destructive");
|
||||
});
|
||||
|
||||
it("renders the all-clear state with the count checked", () => {
|
||||
mock(
|
||||
ok({
|
||||
fired: false,
|
||||
day: "2026-06-03",
|
||||
vitals: [deviation("RESTING_HEART_RATE", false), deviation("WEIGHT", false)],
|
||||
contributing: [],
|
||||
}),
|
||||
);
|
||||
const html = render(<CoincidentDeviationCard />);
|
||||
expect(html).toContain('data-state="all-clear"');
|
||||
expect(html).toContain("All your vitals are within their personal range");
|
||||
expect(html).toContain("text-success");
|
||||
// No alarm tone.
|
||||
expect(html).not.toContain("border-warning");
|
||||
expect(html).not.toContain("text-destructive");
|
||||
});
|
||||
|
||||
it("renders the watch state for a single out-of-band vital, not the fired tone", () => {
|
||||
mock(
|
||||
ok({
|
||||
fired: false,
|
||||
day: "2026-06-03",
|
||||
vitals: [deviation("RESTING_HEART_RATE", true), deviation("WEIGHT", false)],
|
||||
contributing: [deviation("RESTING_HEART_RATE", true)],
|
||||
}),
|
||||
);
|
||||
const html = render(<CoincidentDeviationCard />);
|
||||
expect(html).toContain('data-state="watch"');
|
||||
expect(html).toContain("One vital is outside its usual range");
|
||||
// Watch never uses the fired/alert border or red.
|
||||
expect(html).not.toContain("border-warning");
|
||||
expect(html).not.toContain("text-destructive");
|
||||
});
|
||||
|
||||
it("renders the fired state with the named vitals + the possible-factors line", () => {
|
||||
mock(
|
||||
ok({
|
||||
fired: true,
|
||||
day: "2026-06-03",
|
||||
vitals: [
|
||||
deviation("RESTING_HEART_RATE", true),
|
||||
deviation("RESPIRATORY_RATE", true),
|
||||
],
|
||||
contributing: [
|
||||
deviation("RESTING_HEART_RATE", true),
|
||||
deviation("RESPIRATORY_RATE", true),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const html = render(<CoincidentDeviationCard />);
|
||||
expect(html).toContain('data-state="fired"');
|
||||
expect(html).toContain('data-slot="coincident-factors"');
|
||||
// The load-bearing framing line.
|
||||
expect(html).toContain("Possible factors — never a cause");
|
||||
expect(html).toContain("not a diagnosis");
|
||||
// At most amber — never destructive/red.
|
||||
expect(html).toContain("border-warning");
|
||||
expect(html).not.toContain("text-destructive");
|
||||
// The provenance affordance reaches the card.
|
||||
expect(html).toContain('data-slot="provenance-explainer-trigger"');
|
||||
});
|
||||
|
||||
it("softens a fired flag to the watch tone when history is thin", () => {
|
||||
mock(
|
||||
ok(
|
||||
{
|
||||
fired: true,
|
||||
day: "2026-06-03",
|
||||
vitals: [
|
||||
deviation("RESTING_HEART_RATE", true),
|
||||
deviation("RESPIRATORY_RATE", true),
|
||||
],
|
||||
contributing: [
|
||||
deviation("RESTING_HEART_RATE", true),
|
||||
deviation("RESPIRATORY_RATE", true),
|
||||
],
|
||||
},
|
||||
"low",
|
||||
),
|
||||
);
|
||||
const html = render(<CoincidentDeviationCard />);
|
||||
// Two contributing + fired, but thin history → watch tone, not fired.
|
||||
expect(html).toContain('data-state="watch"');
|
||||
expect(html).not.toContain('data-state="fired"');
|
||||
expect(html).not.toContain("border-warning");
|
||||
// The coverage meter accompanies the softened flag.
|
||||
expect(html).toContain('data-slot="coverage-meter"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle2, AlertTriangle, Activity } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
|
||||
import { CoverageMeter } from "@/components/insights/derived/coverage-meter";
|
||||
import { ProvenanceExplainer } from "@/components/insights/derived/provenance-explainer";
|
||||
import { METRIC_PROVENANCE } from "@/components/insights/derived/standards";
|
||||
import { useDerivedMetric } from "@/components/insights/derived/use-derived-metric";
|
||||
// Type-only — the compute payload never drags the server graph into the bundle
|
||||
// (the v1.9.0 lesson, mirrored at vitals-dashboard.tsx).
|
||||
import type { CoincidentDeviationValue } from "@/lib/insights/derived/coincident-deviation";
|
||||
import type { DerivedProvenance } from "@/lib/insights/derived/types";
|
||||
|
||||
/**
|
||||
* v1.10.3 — "Today's signal" headline card.
|
||||
*
|
||||
* Promotes the COINCIDENT_DEVIATION flag from one buried below-the-fold vitals
|
||||
* tile (painted only when it fired) to a dedicated, always-mounted card at the
|
||||
* top of the Insights overview, matching the always-present pattern Apple
|
||||
* Vitals / WHOOP Health Monitor / Oura Symptom Radar use. The signal is the
|
||||
* daily headline read, not an alarm: an "all clear" day is itself the
|
||||
* reassuring product.
|
||||
*
|
||||
* It renders four calm states off the SINGLE existing `Derived` payload — no
|
||||
* new engine math, no new route, no schema change:
|
||||
* - insufficient (< 2 banded vitals) → "building your baselines" + coverage.
|
||||
* - all-clear (ok, !fired, 0 contributing) → a green check + the count
|
||||
* checked.
|
||||
* - watch (ok, !fired, 1 contributing) → a calm, non-alert line naming the
|
||||
* one vital.
|
||||
* - fired (ok, fired, ≥ 2 contributing) → an amber awareness card naming the
|
||||
* vitals + the load-bearing "possible factors — never a cause" line.
|
||||
*
|
||||
* Restraint guarantees: at most the `warning` (amber) band — never
|
||||
* `destructive`/red, never a score, never a chart (so no Recharts), never a
|
||||
* push. When `confidence.band` is thin (low/draft) the fired tone is softened
|
||||
* to the watch tone + the coverage meter, so a multi-signal flag from too
|
||||
* little history never reads as a confident verdict.
|
||||
*/
|
||||
|
||||
const COINCIDENT_METRIC = "COINCIDENT_DEVIATION";
|
||||
|
||||
interface CoincidentDeviationCardProps {
|
||||
/** Gate the underlying derived-metric read (e.g. on the auth flag). */
|
||||
enabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** The four calm visual states, derived from the single payload. */
|
||||
type SignalState = "insufficient" | "all-clear" | "watch" | "fired";
|
||||
|
||||
/** The provenance ⓘ explainer for the coincident flag, wired from the map. */
|
||||
function CoincidentProvenance({
|
||||
provenance,
|
||||
}: {
|
||||
provenance: DerivedProvenance;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const meta = METRIC_PROVENANCE.COINCIDENT_DEVIATION;
|
||||
const method = (
|
||||
<>
|
||||
{meta.caveatKey ? (
|
||||
<span className="text-warning block font-medium">
|
||||
{t(meta.caveatKey)}
|
||||
</span>
|
||||
) : null}
|
||||
{t(meta.methodKey)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<ProvenanceExplainer
|
||||
provenance={provenance}
|
||||
method={method}
|
||||
standard={meta.standard}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The card shell — uppercase label + the provenance affordance, with the
|
||||
* state-specific body as children. Keeps every state on one card geometry so
|
||||
* the page reserves a stable footprint.
|
||||
*/
|
||||
function CardShell({
|
||||
state,
|
||||
provenance,
|
||||
children,
|
||||
}: {
|
||||
state: SignalState;
|
||||
provenance?: DerivedProvenance;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div
|
||||
data-slot="coincident-deviation-card"
|
||||
data-state={state}
|
||||
className={cn(
|
||||
"bg-card flex w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6",
|
||||
// At most amber — never destructive/red. The fired state borders
|
||||
// warning; every calmer state keeps the neutral card border.
|
||||
state === "fired" ? "border-warning/40" : "border-border",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground truncate text-xs font-medium tracking-wide uppercase">
|
||||
{t("insights.derived.coincident.cardTitle")}
|
||||
</span>
|
||||
{provenance ? <CoincidentProvenance provenance={provenance} /> : null}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A fixed-footprint skeleton matching the resolved card (CLS-safe). */
|
||||
function CardSkeleton() {
|
||||
return (
|
||||
<div
|
||||
data-slot="coincident-deviation-card-skeleton"
|
||||
aria-hidden="true"
|
||||
className="bg-card border-border flex w-full min-w-0 flex-col gap-3 rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
<div className="bg-muted/40 h-3 w-28 rounded" />
|
||||
<div className="bg-muted/40 h-5 w-3/4 rounded" />
|
||||
<div className="bg-muted/40 h-3 w-1/2 rounded" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CoincidentDeviationCard({
|
||||
enabled = true,
|
||||
className,
|
||||
}: CoincidentDeviationCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const { data } = useDerivedMetric<CoincidentDeviationValue>(
|
||||
COINCIDENT_METRIC,
|
||||
{ enabled },
|
||||
);
|
||||
|
||||
// CLS-safe placeholder while the single read is in flight.
|
||||
if (!data) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<CardSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Building the baselines — fewer than two banded vitals. Calm, never an
|
||||
// alarm, never blank.
|
||||
if (data.status === "insufficient") {
|
||||
return (
|
||||
<div className={className}>
|
||||
<CardShell state="insufficient" provenance={data.provenance}>
|
||||
<p
|
||||
className="text-muted-foreground text-sm"
|
||||
data-slot="coincident-building"
|
||||
>
|
||||
{t("insights.derived.coincident.building")}
|
||||
</p>
|
||||
<CoverageMeter coverage={data.coverage} />
|
||||
</CardShell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const v = data.value!;
|
||||
const contributing = v.contributing;
|
||||
const names = contributing
|
||||
.map((d) => {
|
||||
const labelKey = MEASUREMENT_TYPE_LABEL_KEYS[d.type];
|
||||
return labelKey ? t(labelKey) : d.type;
|
||||
})
|
||||
.join(", ");
|
||||
|
||||
// Thin history behind the deepest contributing vital → soften a fired flag
|
||||
// to the watch tone rather than presenting a confident multi-signal verdict
|
||||
// from too little data. Read straight off the existing payload.
|
||||
const band = data.confidence?.band;
|
||||
const thinHistory = band === "low" || band === "draft";
|
||||
|
||||
// Map the single payload onto one of the four calm states.
|
||||
const state: SignalState =
|
||||
contributing.length === 0
|
||||
? "all-clear"
|
||||
: v.fired && contributing.length >= 2 && !thinHistory
|
||||
? "fired"
|
||||
: "watch";
|
||||
|
||||
if (state === "all-clear") {
|
||||
return (
|
||||
<div className={className}>
|
||||
<CardShell state="all-clear" provenance={data.provenance}>
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckCircle2
|
||||
className="text-success mt-0.5 h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p
|
||||
className="text-foreground text-sm font-medium"
|
||||
data-slot="coincident-headline"
|
||||
>
|
||||
{t("insights.derived.coincident.allClear")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{t("insights.derived.coincident.allClearMeta", {
|
||||
count: v.vitals.length,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardShell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "watch") {
|
||||
return (
|
||||
<div className={className}>
|
||||
<CardShell state="watch" provenance={data.provenance}>
|
||||
<div className="flex items-start gap-2">
|
||||
<Activity
|
||||
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p
|
||||
className="text-foreground text-sm font-medium"
|
||||
data-slot="coincident-headline"
|
||||
>
|
||||
{t("insights.derived.coincident.watch")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{t("insights.derived.coincident.watchVital", { vital: names })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{thinHistory ? <CoverageMeter coverage={data.coverage} /> : null}
|
||||
</CardShell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// fired — amber awareness, named vitals, possible-factors line (mandatory).
|
||||
return (
|
||||
<div className={className}>
|
||||
<CardShell state="fired" provenance={data.provenance}>
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle
|
||||
className="text-warning mt-0.5 h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p
|
||||
className="text-foreground text-sm font-medium"
|
||||
data-slot="coincident-headline"
|
||||
>
|
||||
{t("insights.derived.coincident.firedHeadline", {
|
||||
count: contributing.length,
|
||||
})}
|
||||
</p>
|
||||
<p
|
||||
className="text-muted-foreground text-xs leading-snug"
|
||||
data-slot="coincident-vitals"
|
||||
>
|
||||
{t("insights.derived.coincident.vitals", { list: names })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
className="text-muted-foreground text-xs leading-snug"
|
||||
data-slot="coincident-factors"
|
||||
>
|
||||
{t("insights.derived.coincident.factors")}
|
||||
</p>
|
||||
</CardShell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -173,36 +173,16 @@ describe("<VitalsDashboard>", () => {
|
||||
expect(html).not.toContain('data-metric="BMI"');
|
||||
});
|
||||
|
||||
it("surfaces the coincident-deviation flag when it fired", () => {
|
||||
it("does not read the coincident-deviation flag (now the top-of-overview card)", () => {
|
||||
// The flag moved to the dedicated `CoincidentDeviationCard`; the dashboard
|
||||
// batch no longer requests it and the grid never paints it.
|
||||
let requestedCoincident = false;
|
||||
mockBatch((token) => {
|
||||
if (token.metric === "COINCIDENT_DEVIATION") {
|
||||
return ok({
|
||||
fired: true,
|
||||
day: "2026-06-02",
|
||||
vitals: [],
|
||||
contributing: [
|
||||
{ type: "RESTING_HEART_RATE", value: 70, center: 55, low: 48, high: 62, outside: true, direction: "above" },
|
||||
{ type: "RESPIRATORY_RATE", value: 20, center: 14, low: 12, high: 16, outside: true, direction: "above" },
|
||||
],
|
||||
});
|
||||
}
|
||||
return insufficient("no_readings_in_window");
|
||||
});
|
||||
const html = render(<VitalsDashboard />);
|
||||
expect(html).toContain('data-metric="COINCIDENT_DEVIATION"');
|
||||
expect(html).toContain('data-state="fired"');
|
||||
// The provenance affordance reaches the flag.
|
||||
expect(html).toContain('data-slot="provenance-explainer-trigger"');
|
||||
});
|
||||
|
||||
it("hides the coincident-deviation flag when it did not fire", () => {
|
||||
mockBatch((token) => {
|
||||
if (token.metric === "COINCIDENT_DEVIATION") {
|
||||
return ok({ fired: false, day: "2026-06-02", vitals: [], contributing: [] });
|
||||
}
|
||||
if (token.metric === "COINCIDENT_DEVIATION") requestedCoincident = true;
|
||||
return insufficient("no_readings_in_window");
|
||||
});
|
||||
const html = render(<VitalsDashboard />);
|
||||
expect(requestedCoincident).toBe(false);
|
||||
expect(html).not.toContain('data-metric="COINCIDENT_DEVIATION"');
|
||||
});
|
||||
});
|
||||
@@ -29,9 +29,7 @@ import type { FitnessAgeValue } from "@/lib/insights/derived/fitness-age";
|
||||
import type { VascularAgeDeltaValue } from "@/lib/insights/derived/vascular-age";
|
||||
import type { HrvBalanceValue } from "@/lib/insights/derived/hrv-balance";
|
||||
import type { BmiValue } from "@/lib/insights/derived/bmi";
|
||||
import type { CoincidentDeviationValue } from "@/lib/insights/derived/coincident-deviation";
|
||||
import type { DerivedProvenance } from "@/lib/insights/derived/types";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
/**
|
||||
* v1.10.0 — the Vitals dashboard surface (Apple-Health-Highlights grid).
|
||||
@@ -358,59 +356,6 @@ function BmiTile({ read, isLoading }: TileProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Coincident-deviation flag tile — surfaces ONLY when the flag fired today
|
||||
* (≥ N vitals outside their personal band on the same day). Lists the
|
||||
* contributing vitals as plain text and carries the provenance affordance
|
||||
* (the Hampel/Leys median ± k·MAD basis + the descriptive-not-clinical
|
||||
* caveat). When the flag has not fired the tile un-mounts entirely — never
|
||||
* an alarming empty card.
|
||||
*/
|
||||
function CoincidentDeviationTile({ read, isLoading }: TileProps) {
|
||||
const { t } = useTranslations();
|
||||
const data = read<CoincidentDeviationValue>({
|
||||
metric: "COINCIDENT_DEVIATION",
|
||||
});
|
||||
if (isLoading || !data || data.status !== "ok" || !data.value) return null;
|
||||
const v = data.value;
|
||||
if (!v.fired || v.contributing.length === 0) return null;
|
||||
|
||||
const names = v.contributing
|
||||
.map((d) => {
|
||||
const labelKey = MEASUREMENT_TYPE_LABEL_KEYS[d.type];
|
||||
return labelKey ? t(labelKey) : d.type;
|
||||
})
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="vitals-tile"
|
||||
data-metric="COINCIDENT_DEVIATION"
|
||||
data-state="fired"
|
||||
className="bg-card border-warning/40 flex h-full w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground truncate text-xs font-medium tracking-wide uppercase">
|
||||
{t("insights.derived.coincident.label")}
|
||||
</span>
|
||||
<MetricProvenance
|
||||
metric="COINCIDENT_DEVIATION"
|
||||
provenance={data.provenance}
|
||||
/>
|
||||
<AlertTriangle className="text-warning h-4 w-4 shrink-0" />
|
||||
</div>
|
||||
<p className="text-foreground text-sm" data-slot="coincident-summary">
|
||||
{t("insights.derived.coincident.summary", {
|
||||
count: v.contributing.length,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{t("insights.derived.coincident.vitals", { list: names })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A loading placeholder occupying the resolved tile footprint so the grid
|
||||
* reserves its final height and the real tiles drop in without a layout
|
||||
@@ -442,17 +387,6 @@ function VitalsTileSkeleton() {
|
||||
* above — if a new tile gate changes, reflect it here.
|
||||
*/
|
||||
function hasRenderableVital(read: DerivedBatchRead): boolean {
|
||||
const coincident = read<CoincidentDeviationValue>({
|
||||
metric: "COINCIDENT_DEVIATION",
|
||||
});
|
||||
if (
|
||||
coincident?.status === "ok" &&
|
||||
coincident.value?.fired &&
|
||||
coincident.value.contributing.length > 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const fitness = read<FitnessAgeValue>({ metric: "FITNESS_AGE" });
|
||||
if (fitness?.status === "ok" && fitness.value) return true;
|
||||
|
||||
@@ -492,8 +426,10 @@ function hasRenderableVital(read: DerivedBatchRead): boolean {
|
||||
|
||||
/**
|
||||
* The full set of tokens the dashboard reads in one batch — the five
|
||||
* wellness scores + the four derived re-frames + the coincident-deviation
|
||||
* flag + one baseline per vital (minus HRV, which has its own balance tile).
|
||||
* wellness scores + the four derived re-frames + one baseline per vital
|
||||
* (minus HRV, which has its own balance tile). The coincident-deviation
|
||||
* flag is no longer read here: it is now the dedicated "Today's signal"
|
||||
* card at the top of the overview (`CoincidentDeviationCard`).
|
||||
*/
|
||||
function dashboardTokens(): DerivedBatchToken[] {
|
||||
const tokens: DerivedBatchToken[] = [
|
||||
@@ -506,7 +442,6 @@ function dashboardTokens(): DerivedBatchToken[] {
|
||||
{ metric: "VASCULAR_AGE_DELTA" },
|
||||
{ metric: "HRV_BALANCE" },
|
||||
{ metric: "BMI" },
|
||||
{ metric: "COINCIDENT_DEVIATION" },
|
||||
];
|
||||
for (const type of SECTION_VITALS) {
|
||||
if (type === "HEART_RATE_VARIABILITY") continue;
|
||||
@@ -604,7 +539,6 @@ export function VitalsDashboard({ enabled = true, className }: DashboardProps) {
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<CoincidentDeviationTile {...tileProps} />
|
||||
<FitnessAgeTile {...tileProps} />
|
||||
<VascularAgeTile {...tileProps} />
|
||||
<HrvBalanceTile {...tileProps} />
|
||||
|
||||
Reference in new issue
Block a user