mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
245 lines
8.8 KiB
TypeScript
245 lines
8.8 KiB
TypeScript
"use client";
|
||
|
||
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 } 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";
|
||
import { cn } from "@/lib/utils";
|
||
import type { CorrelationResult } from "@/lib/insights/correlations";
|
||
import type { CoachScopeSource } from "@/lib/ai/coach/types";
|
||
import { CONFIDENCE_BADGE_CLASS } from "./confidence-badge";
|
||
import { AskCoachAction } from "./ask-coach-action";
|
||
import {
|
||
PatternDismissButton,
|
||
PatternDismissedNotice,
|
||
usePatternDismissalOverrides,
|
||
} from "./pattern-dismiss-action";
|
||
|
||
/**
|
||
* v1.4.20 phase B3 — single Correlation card.
|
||
*
|
||
* Each card surfaces ONE pre-defined hypothesis (BP × compliance,
|
||
* mood × pulse, weight × weekday). Below-threshold results render an
|
||
* EmptyState rather than a misleading near-zero scatter.
|
||
*
|
||
* Recharts is dynamic-imported behind the existing
|
||
* `<ScatterCorrelationChart>` wrapper so each card pays the bundle
|
||
* cost only when it actually paints a chart.
|
||
*/
|
||
|
||
const ScatterCorrelationChart = dynamic(
|
||
() =>
|
||
import("@/components/charts/chart-runtime").then((mod) => ({
|
||
default: mod.ScatterCorrelationChart,
|
||
})),
|
||
{
|
||
ssr: false,
|
||
loading: () => (
|
||
// v1.4.43 W11 — mirror the loaded chart's responsive aspect-
|
||
// ratio classes (`scatter-correlation-chart.tsx:100`) so the
|
||
// skeleton reserves the same space across breakpoints. The
|
||
// legacy `h-[180px]` was ~60 px shorter than the painted chart
|
||
// at `sm+` and caused a visible CLS shift on insights mount.
|
||
<Skeleton className="bg-muted/40 aspect-square min-h-[180px] w-full sm:aspect-[3/2] sm:h-auto" />
|
||
),
|
||
},
|
||
);
|
||
|
||
interface CorrelationCardProps {
|
||
/** Correlation runner output. `kind` drives title + subtitle copy. */
|
||
result: CorrelationResult;
|
||
}
|
||
|
||
const TONE_BAR_CLASSNAME: Record<CorrelationResult["kind"], string> = {
|
||
"bp-compliance": "bg-brand-pink",
|
||
"mood-pulse": "bg-info",
|
||
"weight-weekday": "bg-primary",
|
||
};
|
||
|
||
const TITLE_KEY: Record<CorrelationResult["kind"], string> = {
|
||
"bp-compliance": "insights.correlationRow.card.bpComplianceTitle",
|
||
"mood-pulse": "insights.correlationRow.card.moodPulseTitle",
|
||
"weight-weekday": "insights.correlationRow.card.weightWeekdayTitle",
|
||
};
|
||
|
||
const SUBTITLE_KEY: Record<CorrelationResult["kind"], string> = {
|
||
"bp-compliance": "insights.correlationRow.card.bpComplianceSubtitle",
|
||
"mood-pulse": "insights.correlationRow.card.moodPulseSubtitle",
|
||
"weight-weekday": "insights.correlationRow.card.weightWeekdaySubtitle",
|
||
};
|
||
|
||
const CONFIDENCE_LABEL_KEY: Record<"low" | "moderate" | "high", string> = {
|
||
high: "insights.correlationRow.confidenceHigh",
|
||
moderate: "insights.correlationRow.confidenceModerate",
|
||
low: "insights.correlationRow.confidenceLow",
|
||
};
|
||
|
||
// v1.21.0 (C4 H2) — the two sources each correlation pair spans, so the
|
||
// Coach hand-off narrows its snapshot to exactly the relationship the
|
||
// card describes.
|
||
const COACH_SOURCES_BY_KIND: Record<
|
||
CorrelationResult["kind"],
|
||
CoachScopeSource[]
|
||
> = {
|
||
"bp-compliance": ["bp", "compliance"],
|
||
"mood-pulse": ["mood", "pulse"],
|
||
"weight-weekday": ["weight"],
|
||
};
|
||
|
||
// v1.22 (W6) — i18n keys (resolved at the call site), not hardcoded English.
|
||
const COACH_QUESTION_BY_KIND: Record<CorrelationResult["kind"], string> = {
|
||
"bp-compliance": "insights.coach.seed.correlationBpCompliance",
|
||
"mood-pulse": "insights.coach.seed.correlationMoodPulse",
|
||
"weight-weekday": "insights.coach.seed.correlationWeightWeekday",
|
||
};
|
||
|
||
export function CorrelationCard({ result }: CorrelationCardProps) {
|
||
const { t } = useTranslations();
|
||
const dismissal = usePatternDismissalOverrides();
|
||
const patternId = result.status === "ok" ? result.patternId : undefined;
|
||
const dismissed = dismissal.isDismissed(
|
||
patternId,
|
||
result.status === "ok" && result.dismissed === true,
|
||
);
|
||
const title = t(TITLE_KEY[result.kind]);
|
||
const subtitle = t(SUBTITLE_KEY[result.kind]);
|
||
const coachSources = COACH_SOURCES_BY_KIND[result.kind];
|
||
const coachScope =
|
||
coachSources.length > 0
|
||
? { metric: coachSources[0], also: coachSources.slice(1) }
|
||
: undefined;
|
||
|
||
return (
|
||
<Card
|
||
data-slot="correlation-card"
|
||
data-kind={result.kind}
|
||
className="relative overflow-hidden"
|
||
>
|
||
<span
|
||
aria-hidden="true"
|
||
className={cn(
|
||
"absolute top-3 bottom-3 left-0 w-[3px] rounded-r",
|
||
TONE_BAR_CLASSNAME[result.kind],
|
||
)}
|
||
/>
|
||
<CardHeader>
|
||
<div className="space-y-0.5">
|
||
<TileHeader
|
||
size="sm"
|
||
title={title}
|
||
right={
|
||
result.status === "ok" ? (
|
||
<Badge
|
||
data-slot="correlation-card-confidence"
|
||
variant="outline"
|
||
className={cn(
|
||
"shrink-0 text-xs",
|
||
CONFIDENCE_BADGE_CLASS[result.confidenceBand.label],
|
||
)}
|
||
>
|
||
{t(CONFIDENCE_LABEL_KEY[result.confidenceBand.label])}
|
||
</Badge>
|
||
) : undefined
|
||
}
|
||
/>
|
||
<p className="text-muted-foreground text-xs">{subtitle}</p>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
{dismissed && result.status === "ok" && patternId ? (
|
||
<PatternDismissedNotice
|
||
patternId={patternId}
|
||
onRestored={() => dismissal.setDismissed(patternId, false)}
|
||
onSettled={() => dismissal.clearDismissed(patternId, false)}
|
||
/>
|
||
) : result.status === "ok" ? (
|
||
<>
|
||
<ScatterCorrelationChart
|
||
data={result.points}
|
||
fill={tonesByKind(result.kind)}
|
||
xAxis={{ dataKey: "x", name: result.xLabel }}
|
||
yAxis={{ dataKey: "y", name: result.yLabel }}
|
||
height={180}
|
||
/>
|
||
<p
|
||
data-slot="correlation-card-interpretation"
|
||
className="text-foreground text-sm leading-snug"
|
||
>
|
||
{result.interpretation}
|
||
</p>
|
||
<p
|
||
data-slot="correlation-card-source"
|
||
className="text-muted-foreground text-xs"
|
||
>
|
||
{t("insights.correlationRow.sourceChip", {
|
||
n: result.n,
|
||
window: t("insights.correlationRow.sourceWindowLast30"),
|
||
})}
|
||
</p>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
// Outline variant so the disabled placeholder reads as
|
||
// "this is on the roadmap" rather than "the system is
|
||
// broken right now". Flip back to default when the
|
||
// 7-day experiment feature ships in v1.5.
|
||
variant="outline"
|
||
disabled
|
||
data-slot="correlation-card-cta"
|
||
className="gap-1.5"
|
||
title={t("insights.correlationRow.experimentTooltip")}
|
||
aria-label={t("insights.correlationRow.experimentTooltip")}
|
||
>
|
||
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
|
||
<span>{t("insights.correlationRow.experimentCta")}</span>
|
||
</Button>
|
||
{/* v1.21.0 (C4 H2) — the live Coach hand-off sits beside the
|
||
roadmap CTA: it scopes to both sources in the pair. */}
|
||
<AskCoachAction
|
||
question={t(COACH_QUESTION_BY_KIND[result.kind])}
|
||
scope={coachScope}
|
||
/>
|
||
{patternId ? (
|
||
<PatternDismissButton
|
||
patternId={patternId}
|
||
onDismissed={() => dismissal.setDismissed(patternId, true)}
|
||
onSettled={() => dismissal.clearDismissed(patternId, true)}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<EmptyState
|
||
variant="plain"
|
||
size="compact"
|
||
icon={<Activity className="size-5" />}
|
||
title={t("insights.correlationRow.emptyTitle")}
|
||
description={t("insights.correlationRow.sourceChip", {
|
||
n: result.n,
|
||
window: t("insights.correlationRow.sourceWindowLast30"),
|
||
})}
|
||
/>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function tonesByKind(kind: CorrelationResult["kind"]): string {
|
||
// Keep in step with TONE_BAR_CLASSNAME above — same token per kind.
|
||
switch (kind) {
|
||
case "bp-compliance":
|
||
return "var(--brand-pink)";
|
||
case "mood-pulse":
|
||
return "var(--info)";
|
||
case "weight-weekday":
|
||
return "var(--primary)";
|
||
}
|
||
}
|