refactor(insights): one MetricCrosstab for the three crosstab boards

The mood tag, rated mood factor and cycle phase boards were three
copies of the same list: pair label, neutral signed delta, confidence
chip, detail line, optional dismissal. MetricCrosstab owns that shape;
each board keeps its labels, unit conversion and number formatting.
The cycle board's own confidence strings were identical to the shared
ones and are gone.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 13:24:01 +02:00
1 parent ee2bc8019c
commit 3aadc03424
13 files changed
+338 -363

No files matched your search

+1 -4
View File
@@ -9191,10 +9191,7 @@
"unitSteps": "Schritte",
"unitBpm": "S/min",
"unitMs": "ms",
"unitMood": "Pkt.",
"confidenceLow": "Geringe Sicherheit",
"confidenceMedium": "Mittlere Sicherheit",
"confidenceHigh": "Hohe Sicherheit"
"unitMood": "Pkt."
},
"title": "Zyklus-Erkenntnisse",
"symptomPatternsTitle": "Symptommuster nach Phase",
+1 -4
View File
@@ -9191,10 +9191,7 @@
"unitSteps": "steps",
"unitBpm": "bpm",
"unitMs": "ms",
"unitMood": "pts",
"confidenceLow": "Low confidence",
"confidenceMedium": "Medium confidence",
"confidenceHigh": "High confidence"
"unitMood": "pts"
},
"title": "Cycle insights",
"symptomPatternsTitle": "Symptom patterns by phase",
+1 -4
View File
@@ -9191,10 +9191,7 @@
"unitSteps": "pasos",
"unitBpm": "lpm",
"unitMs": "ms",
"unitMood": "pts",
"confidenceLow": "Confianza baja",
"confidenceMedium": "Confianza media",
"confidenceHigh": "Confianza alta"
"unitMood": "pts"
},
"title": "Análisis del ciclo",
"symptomPatternsTitle": "Patrones de síntomas por fase",
+1 -4
View File
@@ -9191,10 +9191,7 @@
"unitSteps": "pas",
"unitBpm": "bpm",
"unitMs": "ms",
"unitMood": "pts",
"confidenceLow": "Confiance faible",
"confidenceMedium": "Confiance moyenne",
"confidenceHigh": "Confiance élevée"
"unitMood": "pts"
},
"title": "Analyses du cycle",
"symptomPatternsTitle": "Tendances des symptômes par phase",
+1 -4
View File
@@ -9191,10 +9191,7 @@
"unitSteps": "passi",
"unitBpm": "bpm",
"unitMs": "ms",
"unitMood": "pts",
"confidenceLow": "Affidabilità bassa",
"confidenceMedium": "Affidabilità media",
"confidenceHigh": "Affidabilità alta"
"unitMood": "pts"
},
"title": "Analisi del ciclo",
"symptomPatternsTitle": "Pattern dei sintomi per fase",
+1 -4
View File
@@ -9191,10 +9191,7 @@
"unitSteps": "걸음",
"unitBpm": "bpm",
"unitMs": "ms",
"unitMood": "점",
"confidenceLow": "신뢰도 낮음",
"confidenceMedium": "신뢰도 보통",
"confidenceHigh": "신뢰도 높음"
"unitMood": "점"
},
"title": "주기 인사이트",
"symptomPatternsTitle": "단계별 증상 패턴",
+1 -4
View File
@@ -9191,10 +9191,7 @@
"unitSteps": "stappen",
"unitBpm": "bpm",
"unitMs": "ms",
"unitMood": "pnt",
"confidenceLow": "Lage betrouwbaarheid",
"confidenceMedium": "Gemiddelde betrouwbaarheid",
"confidenceHigh": "Hoge betrouwbaarheid"
"unitMood": "pnt"
},
"title": "Cyclusinzichten",
"symptomPatternsTitle": "Symptoompatronen per fase",
+1 -4
View File
@@ -9191,10 +9191,7 @@
"unitSteps": "kroki",
"unitBpm": "ud./min",
"unitMs": "ms",
"unitMood": "pkt",
"confidenceLow": "Niska pewność",
"confidenceMedium": "Średnia pewność",
"confidenceHigh": "Wysoka pewność"
"unitMood": "pkt"
},
"title": "Analizy cyklu",
"symptomPatternsTitle": "Wzorce objawów wg fazy",
+34 -84
View File
@@ -8,7 +8,7 @@ import {
applyDisplayTransformDelta,
getReadingTransform,
} from "@/lib/measurements/display-transform";
import { cn } from "@/lib/utils";
import { MetricCrosstab } from "@/components/insights/metric-crosstab";
/**
* v1.15 — CYCLE_PHASE × vital crosstab card.
@@ -69,18 +69,6 @@ const UNIT_KEY: Partial<Record<CyclePhaseCrosstabDisplay, string>> = {
mood: "cycle.insights.crosstab.unitMood",
};
const CONFIDENCE_KEY: Record<CyclePhaseCrosstabConfidence, string> = {
low: "cycle.insights.crosstab.confidenceLow",
medium: "cycle.insights.crosstab.confidenceMedium",
high: "cycle.insights.crosstab.confidenceHigh",
};
const CONFIDENCE_CLASS: Record<CyclePhaseCrosstabConfidence, string> = {
low: "bg-secondary text-muted-foreground",
medium: "bg-info/15 text-info",
high: "bg-success/15 text-success",
};
/** Whole numbers for steps; one decimal otherwise. */
function fmt(
value: number,
@@ -207,78 +195,40 @@ export function CyclePhaseCrosstab({
const { t } = useTranslations();
const nf = useFormatters();
const unitDisplay = useUnitDisplay();
if (rows.length === 0) return null;
return (
<div data-slot="cycle-phase-crosstab">
<p className="text-muted-foreground mb-2 text-sm">
{t("cycle.insights.crosstab.description")}
</p>
<ul className="divide-border divide-y">
{rows.map((row) => {
const metricLabel = t(
METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey,
);
const {
<MetricCrosstab
slot="cycle-phase-crosstab"
rowSlot="cycle-phase-crosstab-row"
description={t("cycle.insights.crosstab.description")}
rows={rows.map((row) => {
const metricLabel = t(METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey);
const {
unit,
abs: absConv,
delta: deltaConv,
} = rowUnits(row.display, unitDisplay, t);
// `delta` = lutealAvg − follicularAvg: positive = the vital runs
// higher in the luteal phase.
return {
key: row.metricKey,
metricKey: row.metricKey,
pairLabel: t("cycle.insights.crosstab.pairLabel", {
metric: metricLabel,
}),
up: row.delta >= 0,
deltaValue: fmt(deltaConv(row.delta), row.display, nf),
unit,
confidence: row.confidence,
detail: t("cycle.insights.crosstab.detail", {
metric: metricLabel,
lutealAvg: fmt(absConv(row.lutealAvg), row.display, nf),
follicularAvg: fmt(absConv(row.follicularAvg), row.display, nf),
unit,
abs: absConv,
delta: deltaConv,
} = rowUnits(row.display, unitDisplay, t);
// `delta` = lutealAvg − follicularAvg: positive = the vital runs
// higher in the luteal phase. The number stays NEUTRAL — higher is
// good for steps but bad for resting HR, and this board is
// observational, so colouring it would imply a verdict the data
// doesn't support. The sign prefix + `data-direction` carry it.
const up = row.delta >= 0;
const deltaText = `${up ? "+" : ""}${fmt(deltaConv(row.delta), row.display, nf)} ${unit}`;
return (
<li
key={row.metricKey}
className="flex flex-col gap-1.5 py-2"
data-slot="cycle-phase-crosstab-row"
data-metric={row.metricKey}
data-direction={up ? "up" : "down"}
data-confidence={row.confidence}
>
<div className="flex items-center gap-2 text-sm">
<span
className="text-foreground min-w-0 flex-1 truncate"
title={metricLabel}
>
{t("cycle.insights.crosstab.pairLabel", {
metric: metricLabel,
})}
</span>
<span className="text-foreground shrink-0 text-sm font-semibold tabular-nums">
{deltaText}
</span>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-xs font-medium",
CONFIDENCE_CLASS[row.confidence],
)}
>
{t(CONFIDENCE_KEY[row.confidence])}
</span>
</div>
<p className="text-muted-foreground text-xs">
{t("cycle.insights.crosstab.detail", {
metric: metricLabel,
lutealAvg: fmt(absConv(row.lutealAvg), row.display, nf),
follicularAvg: fmt(
absConv(row.follicularAvg),
row.display,
nf,
),
unit,
lutealDays: row.lutealDays,
follicularDays: row.follicularDays,
})}
</p>
</li>
);
})}
</ul>
</div>
lutealDays: row.lutealDays,
follicularDays: row.follicularDays,
}),
};
})}
/>
);
}
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { Moon } from "lucide-react";
import { I18nProvider } from "@/lib/i18n/context";
import { MetricCrosstab, type MetricCrosstabRow } from "../metric-crosstab";
function row(overrides: Partial<MetricCrosstabRow> = {}): MetricCrosstabRow {
return {
key: "a",
metricKey: "restingHeartRate",
pairLabel: "Resting heart rate",
up: true,
deltaValue: "2,5",
unit: "bpm",
confidence: "medium",
detail: "62 vs 59.5 bpm",
...overrides,
};
}
function render(rows: MetricCrosstabRow[]) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MetricCrosstab
slot="board"
rowSlot="board-row"
description="How it differs"
rows={rows}
/>
</I18nProvider>,
);
}
describe("<MetricCrosstab>", () => {
it("renders nothing for no rows", () => {
expect(render([])).toBe("");
});
it("prints the delta as given, signed, and never coloured", () => {
const html = render([row()]);
expect(html).toContain('data-slot="board"');
expect(html).toContain('data-slot="board-row"');
expect(html).toContain("+2,5 bpm");
expect(html).toContain('data-direction="up"');
expect(html).not.toMatch(/text-(success|destructive)/);
});
it("leaves a negative delta's own sign alone", () => {
const html = render([row({ up: false, deltaValue: "-1,2" })]);
expect(html).toContain("-1,2 bpm");
expect(html).not.toContain("+-1,2");
expect(html).toContain('data-direction="down"');
});
it("states confidence through the grade chip", () => {
const html = render([row({ confidence: "high" })]);
expect(html).toMatch(/data-slot="score-pill"[^>]*data-tone="success"/);
expect(html).toContain("High confidence");
});
it("draws the row icon when one is given", () => {
expect(render([row({ icon: Moon })])).toContain("<svg");
expect(render([row()])).not.toContain("<svg");
});
it("collapses a dismissed pattern to its notice", () => {
const html = render([row({ patternId: "p1", dismissed: true })]);
expect(html).not.toContain('data-slot="board-row"');
});
});
+140
View File
@@ -0,0 +1,140 @@
"use client";
import type { LucideIcon } from "lucide-react";
import { CONFIDENCE_TONE, ScorePill } from "@/components/ui/score-pill";
import { useTranslations } from "@/lib/i18n/context";
import {
PatternDismissButton,
PatternDismissedNotice,
usePatternDismissalOverrides,
} from "@/components/insights/pattern-dismiss-action";
/**
* The "this vital on these days vs those days" board: one row per pair, the
* signed difference, a confidence chip, one detail line with both means and
* the day count. Three surfaces carry it — mood tag × metric, rated mood
* factor × metric, cycle phase × vital — and they were three copies of the
* same markup that had begun to differ (the cycle copy printed its numbers
* with a full stop in every language).
*
* The caller owns the meaning: it resolves the labels, converts the units and
* formats the numbers (`deltaValue` already in the reader's notation, signed
* when negative). This component owns the shape: the row, the neutral delta
* (a higher value is good on one row and bad on the next, so the number is
* never coloured; the sign and `data-direction` carry the direction), the
* confidence chip, and the optional per-row dismissal.
*/
export type MetricCrosstabConfidence = "low" | "medium" | "high";
export interface MetricCrosstabRow {
/** React key. */
key: string;
/** `data-metric` on the row. */
metricKey: string;
pairLabel: string;
icon?: LucideIcon | null;
/** True when the difference is zero or positive. */
up: boolean;
/** The difference in the reader's notation, with its minus sign. */
deltaValue: string;
unit: string;
confidence: MetricCrosstabConfidence;
detail: string;
/** A dismissible pattern; omitted on boards that cannot be dismissed. */
patternId?: string;
canonicalKey?: string;
dismissed?: boolean;
}
const CONFIDENCE_KEY: Record<MetricCrosstabConfidence, string> = {
low: "insights.mood.influence.confidenceLow",
medium: "insights.mood.influence.confidenceMedium",
high: "insights.mood.influence.confidenceHigh",
};
export function MetricCrosstab({
slot,
rowSlot,
description,
rows,
}: {
/** `data-slot` of the board. */
slot: string;
/** `data-slot` of each row. */
rowSlot: string;
description: string;
rows: readonly MetricCrosstabRow[];
}) {
const { t } = useTranslations();
const dismissal = usePatternDismissalOverrides();
if (rows.length === 0) return null;
return (
<div data-slot={slot}>
<p className="text-muted-foreground mb-2 text-sm">{description}</p>
<ul className="divide-border divide-y">
{rows.map((row) => {
const patternId = row.patternId;
if (
patternId &&
dismissal.isDismissed(patternId, row.dismissed === true)
) {
return (
<li key={row.canonicalKey ?? patternId} className="py-2">
<PatternDismissedNotice
compact
patternId={patternId}
onRestored={() => dismissal.setDismissed(patternId, false)}
onSettled={() => dismissal.clearDismissed(patternId, false)}
/>
</li>
);
}
const Icon = row.icon;
return (
<li
key={row.key}
className="flex flex-col gap-1.5 py-2"
data-slot={rowSlot}
data-metric={row.metricKey}
data-direction={row.up ? "up" : "down"}
data-confidence={row.confidence}
>
<div className="flex items-center gap-2 text-sm">
{Icon ? (
<Icon
className="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
) : null}
<span
className="text-foreground min-w-0 flex-1 truncate"
title={row.pairLabel}
>
{row.pairLabel}
</span>
<span className="text-foreground shrink-0 text-sm font-semibold tabular-nums">
{`${row.up ? "+" : ""}${row.deltaValue} ${row.unit}`.trim()}
</span>
<ScorePill tone={CONFIDENCE_TONE[row.confidence]}>
{t(CONFIDENCE_KEY[row.confidence])}
</ScorePill>
{patternId ? (
<PatternDismissButton
patternId={patternId}
onDismissed={() => dismissal.setDismissed(patternId, true)}
onSettled={() => dismissal.clearDismissed(patternId, true)}
className="shrink-0"
/>
) : null}
</div>
<p className="text-muted-foreground text-xs">{row.detail}</p>
</li>
);
})}
</ul>
</div>
);
}
@@ -4,13 +4,8 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { Formatters } from "@/lib/format-locale";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { moodTagIcon } from "@/components/mood/mood-tag-icons";
import { cn } from "@/lib/utils";
import { MetricCrosstab } from "@/components/insights/metric-crosstab";
import type { MoodInfluenceConfidence } from "./mood-tag-influence";
import {
PatternDismissButton,
PatternDismissedNotice,
usePatternDismissalOverrides,
} from "@/components/insights/pattern-dismiss-action";
/**
* v1.14.0 — RATED-factor × vital crosstab card.
@@ -72,18 +67,6 @@ const UNIT_KEY: Record<MoodFactorCrosstabDisplay, string> = {
mmHg: "insights.mood.factorCrosstab.unitMmHg",
};
const CONFIDENCE_KEY: Record<MoodInfluenceConfidence, string> = {
low: "insights.mood.influence.confidenceLow",
medium: "insights.mood.influence.confidenceMedium",
high: "insights.mood.influence.confidenceHigh",
};
const CONFIDENCE_CLASS: Record<MoodInfluenceConfidence, string> = {
low: "bg-secondary text-muted-foreground",
medium: "bg-info/15 text-info",
high: "bg-success/15 text-success",
};
/** Whole numbers for steps; one decimal otherwise. */
function formatValue(
value: number,
@@ -101,120 +84,56 @@ export function MoodFactorMetricCrosstab({
const { t } = useTranslations();
const nf = useFormatters();
const unitDisplay = useUnitDisplay();
const dismissal = usePatternDismissalOverrides();
if (rows.length === 0) return null;
return (
<div data-slot="mood-factor-metric-crosstab">
<p className="text-muted-foreground mb-2 text-sm">
{t("insights.mood.factorCrosstab.description")}
</p>
<ul className="divide-border divide-y">
{rows.map((row) => {
const patternId = row.patternId;
if (
patternId &&
dismissal.isDismissed(patternId, row.dismissed === true)
) {
return (
<li key={row.canonicalKey ?? patternId} className="py-2">
<PatternDismissedNotice
compact
patternId={patternId}
onRestored={() => dismissal.setDismissed(patternId, false)}
onSettled={() => dismissal.clearDismissed(patternId, false)}
/>
</li>
);
}
const Icon = moodTagIcon(row.icon);
const factorLabel = t(row.labelKey);
const metricLabel = t(
METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey,
);
const pairLabel = t("insights.mood.factorCrosstab.pairLabel", {
<MetricCrosstab
slot="mood-factor-metric-crosstab"
rowSlot="mood-factor-crosstab-row"
description={t("insights.mood.factorCrosstab.description")}
rows={rows.map((row) => {
const factorLabel = t(row.labelKey);
const metricLabel = t(METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey);
// The weight ("kg") row follows the metric/imperial preference: the
// unit symbol and the values (averages and delta, all in kg,
// offset-free) convert together. Every other display is unitless to
// the preference and keeps its i18n label and raw value.
const isWeightRow = row.display === "kg";
const unit = isWeightRow
? unitDisplay.unitFor("WEIGHT")
: t(UNIT_KEY[row.display]);
const conv = (v: number) =>
isWeightRow ? unitDisplay.toDisplay("WEIGHT", v) : v;
// `delta` = lowAvg − highAvg: positive = the vital runs higher on the
// low-factor (worse) days.
return {
key: `${row.metricKey}:${row.factor}`,
metricKey: row.metricKey,
pairLabel: t("insights.mood.factorCrosstab.pairLabel", {
factor: factorLabel,
metric: metricLabel,
});
// v1.32.26 — the weight ("kg") row follows the metric/imperial
// preference: the unit symbol + the values (averages + delta, all in
// kg, offset-free) convert together. Every other display is unitless
// to the preference and keeps its i18n label + raw value.
const isWeightRow = row.display === "kg";
const unit = isWeightRow
? unitDisplay.unitFor("WEIGHT")
: t(UNIT_KEY[row.display]);
const conv = (v: number) =>
isWeightRow ? unitDisplay.toDisplay("WEIGHT", v) : v;
// `delta` = lowAvg − highAvg: positive = the vital runs higher on the
// low-factor (worse) days. The number stays NEUTRAL — a higher value
// is good for steps but bad for resting HR, and this board is
// observational ("associations, not causes"), so colouring it
// green/red would imply a health verdict the data doesn't support.
// The sign prefix + `data-direction` carry the direction.
const up = row.delta >= 0;
const deltaText = `${up ? "+" : ""}${formatValue(conv(row.delta), row.display, nf)} ${unit}`;
return (
<li
key={`${row.metricKey}:${row.factor}`}
className="flex flex-col gap-1.5 py-2"
data-slot="mood-factor-crosstab-row"
data-metric={row.metricKey}
data-direction={up ? "up" : "down"}
data-confidence={row.confidence}
>
<div className="flex items-center gap-2 text-sm">
{Icon && (
<Icon
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
)}
<span
className="text-foreground min-w-0 flex-1 truncate"
title={pairLabel}
>
{pairLabel}
</span>
<span className="text-foreground shrink-0 text-sm font-semibold tabular-nums">
{deltaText}
</span>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-xs font-medium",
CONFIDENCE_CLASS[row.confidence],
)}
>
{t(CONFIDENCE_KEY[row.confidence])}
</span>
{patternId ? (
<PatternDismissButton
patternId={patternId}
onDismissed={() => dismissal.setDismissed(patternId, true)}
onSettled={() => dismissal.clearDismissed(patternId, true)}
className="shrink-0"
/>
) : null}
</div>
<p className="text-muted-foreground text-xs">
{t(
row.inverse
? "insights.mood.factorCrosstab.detailInverse"
: "insights.mood.factorCrosstab.detail",
{
factor: factorLabel,
metric: metricLabel,
lowAvg: formatValue(conv(row.lowAvg), row.display, nf),
highAvg: formatValue(conv(row.highAvg), row.display, nf),
unit,
lowDays: row.lowDays,
},
)}
</p>
</li>
);
})}
</ul>
</div>
}),
icon: moodTagIcon(row.icon),
up: row.delta >= 0,
deltaValue: formatValue(conv(row.delta), row.display, nf),
unit,
confidence: row.confidence,
detail: t(
row.inverse
? "insights.mood.factorCrosstab.detailInverse"
: "insights.mood.factorCrosstab.detail",
{
factor: factorLabel,
metric: metricLabel,
lowAvg: formatValue(conv(row.lowAvg), row.display, nf),
highAvg: formatValue(conv(row.highAvg), row.display, nf),
unit,
lowDays: row.lowDays,
},
),
patternId: row.patternId,
canonicalKey: row.canonicalKey,
dismissed: row.dismissed,
};
})}
/>
);
}
@@ -3,13 +3,8 @@
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { Formatters } from "@/lib/format-locale";
import { moodTagIcon } from "@/components/mood/mood-tag-icons";
import { cn } from "@/lib/utils";
import { MetricCrosstab } from "@/components/insights/metric-crosstab";
import type { MoodInfluenceConfidence } from "./mood-tag-influence";
import {
PatternDismissButton,
PatternDismissedNotice,
usePatternDismissalOverrides,
} from "@/components/insights/pattern-dismiss-action";
/**
* v1.12.0 — tag × health-metric crosstab card.
@@ -76,22 +71,6 @@ const UNIT_KEY: Record<MoodCrosstabDisplay, string> = {
ms: "insights.mood.crosstab.unitMs",
};
const CONFIDENCE_KEY: Record<MoodInfluenceConfidence, string> = {
low: "insights.mood.influence.confidenceLow",
medium: "insights.mood.influence.confidenceMedium",
high: "insights.mood.influence.confidenceHigh",
};
// Confidence chips carry COLORED TEXT, so they ride the semantic feedback
// tokens (`--info` / `--success`) — these carry the Alucard light-mode
// overrides that clear AA on the white card. Raw `--dracula-*` stays bright
// green/cyan in light mode and fails AA for text.
const CONFIDENCE_CLASS: Record<MoodInfluenceConfidence, string> = {
low: "bg-secondary text-muted-foreground",
medium: "bg-info/15 text-info",
high: "bg-success/15 text-success",
};
/** One decimal for hours/score, whole numbers for kcal. */
function formatValue(
value: number,
@@ -108,103 +87,43 @@ export function MoodTagMetricCrosstab({
}) {
const { t } = useTranslations();
const nf = useFormatters();
const dismissal = usePatternDismissalOverrides();
if (rows.length === 0) return null;
return (
<div data-slot="mood-tag-metric-crosstab">
<p className="text-muted-foreground mb-2 text-sm">
{t("insights.mood.crosstab.description")}
</p>
<ul className="divide-border divide-y">
{rows.map((row) => {
const patternId = row.patternId;
if (
patternId &&
dismissal.isDismissed(patternId, row.dismissed === true)
) {
return (
<li key={row.canonicalKey ?? patternId} className="py-2">
<PatternDismissedNotice
compact
patternId={patternId}
onRestored={() => dismissal.setDismissed(patternId, false)}
onSettled={() => dismissal.clearDismissed(patternId, false)}
/>
</li>
);
}
const Icon = moodTagIcon(row.icon);
const tagLabel = row.label ?? t(row.labelKey);
const metricLabel = t(
METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey,
);
const pairLabel = t("insights.mood.crosstab.pairLabel", {
<MetricCrosstab
slot="mood-tag-metric-crosstab"
rowSlot="mood-crosstab-row"
description={t("insights.mood.crosstab.description")}
rows={rows.map((row) => {
const tagLabel = row.label ?? t(row.labelKey);
const metricLabel = t(METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey);
const unit = t(UNIT_KEY[row.display]);
return {
key: `${row.metricKey}:${row.tag}`,
metricKey: row.metricKey,
pairLabel: t("insights.mood.crosstab.pairLabel", {
tag: tagLabel,
metric: metricLabel,
});
const unit = t(UNIT_KEY[row.display]);
const up = row.delta >= 0;
const deltaText = `${up ? "+" : ""}${formatValue(row.delta, row.display, nf)} ${unit}`;
return (
<li
key={`${row.metricKey}:${row.tag}`}
className="flex flex-col gap-1.5 py-2"
data-slot="mood-crosstab-row"
data-metric={row.metricKey}
data-direction={up ? "up" : "down"}
data-confidence={row.confidence}
>
<div className="flex items-center gap-2 text-sm">
{Icon && (
<Icon
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
)}
<span
className="text-foreground min-w-0 flex-1 truncate"
title={pairLabel}
>
{pairLabel}
</span>
<span className="text-foreground shrink-0 text-sm font-semibold tabular-nums">
{deltaText}
</span>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-xs font-medium",
CONFIDENCE_CLASS[row.confidence],
)}
>
{t(CONFIDENCE_KEY[row.confidence])}
</span>
{patternId ? (
<PatternDismissButton
patternId={patternId}
onDismissed={() => dismissal.setDismissed(patternId, true)}
onSettled={() => dismissal.clearDismissed(patternId, true)}
className="shrink-0"
/>
) : null}
</div>
<p className="text-muted-foreground text-xs">
{t(
row.mode === "nextDay"
? "insights.mood.crosstab.detailNextDay"
: "insights.mood.crosstab.detailSameDay",
{
withAvg: formatValue(row.withAvg, row.display, nf),
withoutAvg: formatValue(row.withoutAvg, row.display, nf),
unit,
withDays: row.withDays,
},
)}
</p>
</li>
);
})}
</ul>
</div>
}),
icon: moodTagIcon(row.icon),
up: row.delta >= 0,
deltaValue: formatValue(row.delta, row.display, nf),
unit,
confidence: row.confidence,
detail: t(
row.mode === "nextDay"
? "insights.mood.crosstab.detailNextDay"
: "insights.mood.crosstab.detailSameDay",
{
withAvg: formatValue(row.withAvg, row.display, nf),
withoutAvg: formatValue(row.withoutAvg, row.display, nf),
unit,
withDays: row.withDays,
},
),
patternId: row.patternId,
canonicalKey: row.canonicalKey,
dismissed: row.dismissed,
};
})}
/>
);
}