mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
ee2bc8019c
commit
3aadc03424
13 files changed
+338
-363
No files matched your search
+1
-4
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -9191,10 +9191,7 @@
|
||||
"unitSteps": "걸음",
|
||||
"unitBpm": "bpm",
|
||||
"unitMs": "ms",
|
||||
"unitMood": "점",
|
||||
"confidenceLow": "신뢰도 낮음",
|
||||
"confidenceMedium": "신뢰도 보통",
|
||||
"confidenceHigh": "신뢰도 높음"
|
||||
"unitMood": "점"
|
||||
},
|
||||
"title": "주기 인사이트",
|
||||
"symptomPatternsTitle": "단계별 증상 패턴",
|
||||
|
||||
+1
-4
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user