mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Show whole-number metrics as whole numbers
The dashboard tiles printed every value at one decimal: '121,0 mmHg',
'60,0 bpm', '9.240,0' steps. TrendCard takes the metric's precision, and
blood pressure, pulse, HRV, steps and mg/dL glucose pass zero. The
measurement list names count-shaped units the way the insights pages do
('13 Etagen', '12,5 Atemzüge/min') instead of the source's 'count'.
This commit is contained in:
1 parent
b80dc14142
commit
554f36aeab
6 files changed
+108
-7
No files matched your search
+11
-1
@@ -28,7 +28,11 @@ import {
|
||||
TrendingUp,
|
||||
Wind,
|
||||
} from "lucide-react";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import {
|
||||
convertGlucose,
|
||||
glucoseFractionDigits,
|
||||
resolveGlucoseUnit,
|
||||
} from "@/lib/glucose";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
resolveDashboardLayout,
|
||||
@@ -1039,6 +1043,7 @@ export default function DashboardPageClient({
|
||||
node: (
|
||||
<TrendCard
|
||||
key="bp-sys"
|
||||
fractionDigits={0}
|
||||
label={t("dashboard.bloodPressureSysShort")}
|
||||
latest={sys?.latest ?? null}
|
||||
unit="mmHg"
|
||||
@@ -1080,6 +1085,7 @@ export default function DashboardPageClient({
|
||||
node: (
|
||||
<TrendCard
|
||||
key="bp-dia"
|
||||
fractionDigits={0}
|
||||
label={t("dashboard.bloodPressureDiaShort")}
|
||||
latest={dia?.latest ?? null}
|
||||
unit="mmHg"
|
||||
@@ -1121,6 +1127,7 @@ export default function DashboardPageClient({
|
||||
node: (
|
||||
<TrendCard
|
||||
key="pulse"
|
||||
fractionDigits={0}
|
||||
label={t("dashboard.pulseShort")}
|
||||
latest={pulseTileSummary?.latest ?? null}
|
||||
unit="bpm"
|
||||
@@ -1243,6 +1250,7 @@ export default function DashboardPageClient({
|
||||
node: (
|
||||
<TrendCard
|
||||
key="steps"
|
||||
fractionDigits={0}
|
||||
label={t("dashboard.stepsShort") ?? "Steps"}
|
||||
latest={stepsSummary?.latest ?? null}
|
||||
unit=""
|
||||
@@ -1303,6 +1311,7 @@ export default function DashboardPageClient({
|
||||
node: (
|
||||
<TrendCard
|
||||
key="hrv"
|
||||
fractionDigits={0}
|
||||
// Name the measure when the RMSSD series is the one on show.
|
||||
// Without it a ring / strap reading (typically 20-60 ms) sits
|
||||
// under the same label as an SDNN one and reads as a collapse
|
||||
@@ -1631,6 +1640,7 @@ export default function DashboardPageClient({
|
||||
: null
|
||||
}
|
||||
unit={displayGlucoseUnit}
|
||||
fractionDigits={glucoseFractionDigits(displayGlucoseUnit)}
|
||||
avg7={
|
||||
s.avg7 != null
|
||||
? convertGlucose(s.avg7, displayGlucoseUnit)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { Activity } from "lucide-react";
|
||||
|
||||
import { TrendCard } from "../trend-card";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* A whole-number metric reads as a whole number on the tile: "121 mmHg",
|
||||
* not "121,0 mmHg", and "9.240" steps rather than "9.240,0".
|
||||
*/
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const base = {
|
||||
label: "Schritte",
|
||||
latest: 9240,
|
||||
unit: "",
|
||||
avg7: 9535.14,
|
||||
avg30: 9100,
|
||||
slope30: null,
|
||||
icon: Activity,
|
||||
};
|
||||
|
||||
describe("<TrendCard> fractionDigits", () => {
|
||||
it("keeps one decimal by default", () => {
|
||||
const html = render(<TrendCard {...base} />);
|
||||
expect(html).toContain("9.240,0");
|
||||
});
|
||||
|
||||
it("prints whole numbers, averages and deltas at 0", () => {
|
||||
const html = render(
|
||||
<TrendCard {...base} fractionDigits={0} trend7Delta={-1383.2} />,
|
||||
);
|
||||
expect(html).toContain("9.240");
|
||||
expect(html).not.toContain("9.240,0");
|
||||
expect(html).toContain("9.535");
|
||||
expect(html).not.toContain("9.535,1");
|
||||
expect(html).toContain("−1.383");
|
||||
expect(html).not.toContain("−1.383,2");
|
||||
});
|
||||
|
||||
it("reads a sub-step delta as zero at 0 decimals", () => {
|
||||
const html = render(
|
||||
<TrendCard {...base} fractionDigits={0} trend7Delta={0.3} />,
|
||||
);
|
||||
expect(html).toContain("±0");
|
||||
});
|
||||
});
|
||||
@@ -101,6 +101,13 @@ interface TrendCardProps {
|
||||
* before (no reserved space, no layout shift).
|
||||
*/
|
||||
valueAdornment?: React.ReactNode;
|
||||
/**
|
||||
* Decimal places for the headline, the averages and the deltas. Defaults
|
||||
* to 1. A whole-number metric (steps, mmHg, bpm, mg/dL) passes 0 so the
|
||||
* tile reads "121 mmHg", not "121,0 mmHg" — the precision its own
|
||||
* insights page uses.
|
||||
*/
|
||||
fractionDigits?: number;
|
||||
}
|
||||
|
||||
export function TrendCard({
|
||||
@@ -123,6 +130,7 @@ export function TrendCard({
|
||||
staleDays = null,
|
||||
emptyHint = null,
|
||||
valueAdornment = null,
|
||||
fractionDigits = 1,
|
||||
}: TrendCardProps) {
|
||||
const { t, tCount } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -152,7 +160,7 @@ export function TrendCard({
|
||||
const arrowSentiment = getTrendSentiment(primarySignal, directionSentiment);
|
||||
const trendColor = sentimentColorClass(arrowSentiment);
|
||||
|
||||
const formatValue = (value: number) => fmt.number(value, 1);
|
||||
const formatValue = (value: number) => fmt.number(value, fractionDigits);
|
||||
|
||||
// v1.4.15 Fix 4 — the 7-day delta now shares the arrow's colour so
|
||||
// both elements communicate the same sentiment for the same signal.
|
||||
@@ -171,9 +179,10 @@ export function TrendCard({
|
||||
const comparisonDeltaColor = sentimentColorClass(comparisonSentiment);
|
||||
|
||||
const formatDelta = (value: number): string => {
|
||||
if (Math.abs(value) < 0.05) return `±0`;
|
||||
// Below half a step of the shown precision the delta prints as zero.
|
||||
if (Math.abs(value) < 0.5 * 10 ** -fractionDigits) return `±0`;
|
||||
const sign = value > 0 ? "+" : "−";
|
||||
return `${sign}${fmt.number(Math.abs(value), 1)}`;
|
||||
return `${sign}${fmt.number(Math.abs(value), fractionDigits)}`;
|
||||
};
|
||||
|
||||
// The label flips from "7d" / "7T" (mean) to "7d trend" / "7T-Trend"
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
MEASUREMENT_TYPE_LABEL_KEYS,
|
||||
MEASUREMENT_TYPE_ICONS,
|
||||
MEASUREMENT_TYPE_COLORS,
|
||||
measurementUnitLabel,
|
||||
} from "../measurement-list-meta";
|
||||
import enMessages from "../../../../messages/en.json";
|
||||
import deMessages from "../../../../messages/de.json";
|
||||
@@ -41,3 +42,18 @@ describe("measurement-list-meta", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("measurementUnitLabel", () => {
|
||||
const t = (key: string) => `t:${key}`;
|
||||
it("shows the localised word for count-shaped source units", () => {
|
||||
expect(measurementUnitLabel("FLIGHTS_CLIMBED", "count", t)).toBe(
|
||||
"t:insights.units.flights",
|
||||
);
|
||||
expect(measurementUnitLabel("RESPIRATORY_RATE", "count/min", t)).toBe(
|
||||
"t:insights.units.respiratoryRate",
|
||||
);
|
||||
});
|
||||
it("passes every other unit through", () => {
|
||||
expect(measurementUnitLabel("WEIGHT", "kg", t)).toBe("kg");
|
||||
});
|
||||
});
|
||||
@@ -104,6 +104,7 @@ import {
|
||||
MEASUREMENT_TYPE_LABEL_KEYS as TYPE_LABEL_KEYS,
|
||||
MEASUREMENT_TYPE_ICONS as TYPE_ICONS,
|
||||
MEASUREMENT_TYPE_COLORS as TYPE_COLORS,
|
||||
measurementUnitLabel,
|
||||
} from "./measurement-list-meta";
|
||||
import {
|
||||
measurementListFiltersToSearch,
|
||||
@@ -1262,7 +1263,11 @@ export function MeasurementList({
|
||||
rowDisplay(m).value,
|
||||
rawDisplayFractionDigits(m.type),
|
||||
)}{" "}
|
||||
{isGrouped ? m.unit : rowDisplay(m).unit}
|
||||
{measurementUnitLabel(
|
||||
m.type,
|
||||
isGrouped ? m.unit : rowDisplay(m).unit,
|
||||
t,
|
||||
)}
|
||||
{isGrouped && (
|
||||
<span className="text-muted-foreground ml-2 text-xs font-normal">
|
||||
{t("measurements.dailyTotalCaption", {
|
||||
@@ -1486,7 +1491,11 @@ export function MeasurementList({
|
||||
rowDisplay(m).value,
|
||||
rawDisplayFractionDigits(m.type),
|
||||
)}{" "}
|
||||
{isGrouped ? m.unit : rowDisplay(m).unit}
|
||||
{measurementUnitLabel(
|
||||
m.type,
|
||||
isGrouped ? m.unit : rowDisplay(m).unit,
|
||||
t,
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
+6
-1
@@ -27,6 +27,11 @@ export function mmolToMgdl(mmol: number): number {
|
||||
return Math.round(mmol * MGDL_PER_MMOL);
|
||||
}
|
||||
|
||||
/** Decimal places a glucose value is shown at: whole mg/dL, tenths of mmol/L. */
|
||||
export function glucoseFractionDigits(unit: GlucoseUnit): number {
|
||||
return unit === "mmol/L" ? 1 : 0;
|
||||
}
|
||||
|
||||
export function convertGlucose(value: number, to: GlucoseUnit): number {
|
||||
return to === "mmol/L" ? mgdlToMmol(value) : Math.round(value);
|
||||
}
|
||||
@@ -80,7 +85,7 @@ export function glucoseBandParams(
|
||||
unit: GlucoseUnit,
|
||||
formatNumber: (value: number, fractionDigits: number) => string,
|
||||
): Record<string, string> {
|
||||
const digits = unit === "mmol/L" ? 1 : 0;
|
||||
const digits = glucoseFractionDigits(unit);
|
||||
const v = (point: GlucoseCutPoint) =>
|
||||
formatNumber(unit === "mmol/L" ? point.mmol : point.mgdl, digits);
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user