`;
+ // the rule refuses a directive without one. See the rule header.
+ "healthlog/no-unlocalised-decimal": "error",
},
},
// v1.28.17 — every recharts-rendering component funnels through the
diff --git a/src/components/admin/encryption-section.tsx b/src/components/admin/encryption-section.tsx
index 78230e0cf..58d8fe7c9 100644
--- a/src/components/admin/encryption-section.tsx
+++ b/src/components/admin/encryption-section.tsx
@@ -229,11 +229,11 @@ export function EncryptionSection() {
/>
{/* "Safe to drop the legacy key" needs a legacy key to drop. With a
@@ -414,6 +414,7 @@ export function ColumnCoverage({
initiallyOpen?: boolean;
}) {
const { t } = useTranslations();
+ const fmt = useFormatters();
const [open, setOpen] = useState(initiallyOpen);
const [showAll, setShowAll] = useState(false);
const withRows = columns.filter((c) => c.total > 0);
@@ -484,16 +485,12 @@ export function ColumnCoverage({
|
{c.model}.{c.field}
|
-
- {c.total.toLocaleString()}
- |
-
- {active.toLocaleString()}
- |
+ {fmt.integer(c.total)} |
+ {fmt.integer(active)} |
{stale > 0 ? (
- {stale.toLocaleString()}
+ {fmt.integer(stale)}
) : (
0
diff --git a/src/components/admin/general-settings-section.tsx b/src/components/admin/general-settings-section.tsx
index 4182d6e3b..d98b9f0e1 100644
--- a/src/components/admin/general-settings-section.tsx
+++ b/src/components/admin/general-settings-section.tsx
@@ -54,7 +54,8 @@ export function GeneralSettingsSection() {
: "";
const storedQuotaGb =
settings?.documentQuotaBytes != null
- ? String(Math.round((settings.documentQuotaBytes / GIB) * 10) / 10)
+ ? // eslint-disable-next-line healthlog/no-unlocalised-decimal -- value of a type="number" input, which only takes a full stop
+ String(Math.round((settings.documentQuotaBytes / GIB) * 10) / 10)
: "";
const commitMaxFile = () => {
diff --git a/src/components/admin/host-metrics-chart.tsx b/src/components/admin/host-metrics-chart.tsx
index 20987aa64..20377f6be 100644
--- a/src/components/admin/host-metrics-chart.tsx
+++ b/src/components/admin/host-metrics-chart.tsx
@@ -47,6 +47,7 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { apiGet } from "@/lib/api/api-fetch";
import { readStoredTimezone } from "@/lib/timezone-mirror";
+import { resolveIntlLocale } from "@/lib/format-locale";
interface HostMetricsApiSample {
capturedAt: string;
@@ -89,7 +90,10 @@ const COLOR_DISK = "var(--dracula-purple)";
* + the absolute peak BPS used to scale `diskBusyPercent`. Kept as a
* separate export so the test suite doesn't need to render Recharts.
*/
-export function buildChartRows(samples: HostMetricsApiSample[]): {
+export function buildChartRows(
+ samples: HostMetricsApiSample[],
+ intlLocale?: string,
+): {
rows: ChartRow[];
peakDiskBps: number;
hasDiskData: boolean;
@@ -104,7 +108,7 @@ export function buildChartRows(samples: HostMetricsApiSample[]): {
// samples), and an operator reading their own instance should see the same
// clock here as on every other timestamp in the admin area — a spike at
// "14:20" must not be 14:20 here and 15:20 two screens away.
- const formatter = new Intl.DateTimeFormat(undefined, {
+ const formatter = new Intl.DateTimeFormat(intlLocale ?? "en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
@@ -141,7 +145,7 @@ export function buildChartRows(samples: HostMetricsApiSample[]): {
}
export function HostMetricsChart() {
- const { t } = useTranslations();
+ const { t, locale } = useTranslations();
const fmt = useFormatters();
const { data, isLoading, isError, error, refetch } = useQuery({
@@ -156,8 +160,8 @@ export function HostMetricsChart() {
});
const { rows, peakDiskBps, hasDiskData } = useMemo(
- () => buildChartRows(data?.samples ?? []),
- [data?.samples],
+ () => buildChartRows(data?.samples ?? [], resolveIntlLocale(locale)),
+ [data?.samples, locale],
);
// Total physical memory (latest sample) — lets the tooltip pair the
diff --git a/src/components/admin/user-management-section.tsx b/src/components/admin/user-management-section.tsx
index ca9d2863f..2cc1b5aff 100644
--- a/src/components/admin/user-management-section.tsx
+++ b/src/components/admin/user-management-section.tsx
@@ -162,7 +162,8 @@ export function UserManagementSection() {
setEditQuotaGb(
u.documentQuotaBytes === null
? ""
- : String(Math.round((u.documentQuotaBytes / 1_073_741_824) * 10) / 10),
+ : // eslint-disable-next-line healthlog/no-unlocalised-decimal -- value of a type="number" input, which only takes a full stop
+ String(Math.round((u.documentQuotaBytes / 1_073_741_824) * 10) / 10),
);
}
diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx
index 226d273e6..bbfb9196b 100644
--- a/src/components/charts/mood-chart.tsx
+++ b/src/components/charts/mood-chart.tsx
@@ -20,7 +20,11 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardHeader, CardContent } from "@/components/ui/card";
import { TagChip } from "@/components/ui/tag-chip";
import { TileHeader } from "@/components/insights/tile-header";
-import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
+import {
+ useDateFormatPreference,
+ useFormatters,
+ useTranslations,
+} from "@/lib/i18n/context";
import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
import { readStoredTimezone } from "@/lib/timezone-mirror";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
@@ -299,6 +303,7 @@ export function MoodChart({
}: MoodChartProps) {
const { isAuthenticated } = useAuth();
const { t, locale } = useTranslations();
+ const fmt = useFormatters();
// Issue #490 — x-axis tick + tooltip date labels. Every timestamp this
// chart formats encodes a CALENDAR DAY (noon-UTC of a server day key,
// or a `bucketTimeSeries` Berlin-day UTC-midnight bucket start), so
@@ -618,9 +623,9 @@ export function MoodChart({
};
const formatTooltipValue = (value: number): string => {
- const rounded = Math.round(value * 10) / 10;
+ const rounded = fmt.number(Math.round(value * 10) / 10);
const label = moodLabels[Math.round(value)];
- return label ? `${rounded} (${label})` : String(rounded);
+ return label ? `${rounded} (${label})` : rounded;
};
// v1.4.16 — personal mood baseline (median over visible window).
@@ -681,7 +686,7 @@ export function MoodChart({
if (scores.length === 0) {
return t("charts.a11y.noData", { metric });
}
- const fmtScore = (v: number) => String(Math.round(v * 10) / 10);
+ const fmtScore = (v: number) => fmt.number(Math.round(v * 10) / 10);
const minVal = Math.min(...scores);
const maxVal = Math.max(...scores);
const latestVal = scores[scores.length - 1];
@@ -938,7 +943,7 @@ export function MoodChart({
delta = t("charts.deltaUnchanged");
} else {
const sign = diff > 0 ? "+" : "−";
- const formatted = `${sign}${Math.abs(diff).toFixed(1)}`;
+ const formatted = `${sign}${fmt.number(Math.abs(diff), 1)}`;
delta = t(
effectiveCompareBaseline === "lastMonth"
? "comparison.deltaVs.lastMonth"
@@ -951,7 +956,7 @@ export function MoodChart({
delta = t("charts.deltaUnchanged");
} else {
const sign = diff > 0 ? "+" : "−";
- const formatted = `${sign}${Math.abs(diff).toFixed(1)}`;
+ const formatted = `${sign}${fmt.number(Math.abs(diff), 1)}`;
delta = t("charts.deltaVsBaseline").replace(
"{delta}",
formatted,
diff --git a/src/components/charts/mood-heatmap.tsx b/src/components/charts/mood-heatmap.tsx
index 06475692a..cb3d86328 100644
--- a/src/components/charts/mood-heatmap.tsx
+++ b/src/components/charts/mood-heatmap.tsx
@@ -6,7 +6,11 @@ import { useCalendarDate } from "@/hooks/use-calendar-date";
import { dateOnlyKey } from "@/lib/tz/date-only";
import { ChartDataTable } from "./chart-data-table";
import { useState, useMemo, useEffect, useRef } from "react";
-import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context";
+import {
+ useDisplayTimezone,
+ useFormatters,
+ useTranslations,
+} from "@/lib/i18n/context";
import { heatmapDays } from "@/lib/charts/heatmap-days";
import { moodLabelKeyForScore } from "@/lib/mood/labels";
@@ -144,6 +148,7 @@ export function MoodHeatmap({
stretch = false,
}: MoodHeatmapProps) {
const { t } = useTranslations();
+ const fmt = useFormatters();
const timeZone = useDisplayTimezone();
// Day keys are calendar dates ("YYYY-MM-DD"), read as such in every zone.
// A noon-UTC instant formatted in the reader's zone was a day late from
@@ -274,7 +279,7 @@ export function MoodHeatmap({
}
const labelKey = moodLabelKeyForScore(Math.round(cell.cell.score));
const moodLabel = labelKey ? t(labelKey) : "";
- return `${formatDay(cell.dateKey)}: ${cell.cell.score.toFixed(1)}${moodLabel ? ` · ${moodLabel}` : ""}`;
+ return `${formatDay(cell.dateKey)}: ${fmt.number(cell.cell.score, 1)}${moodLabel ? ` · ${moodLabel}` : ""}`;
};
// Only the logged days go into the data table; the empty-tinted cells
// are already covered by the aggregate summary on the SVG, so a rotor
@@ -420,8 +425,8 @@ export function MoodHeatmap({
formatValue={(value) => {
const labelKey = moodLabelKeyForScore(Math.round(value));
return labelKey
- ? `${value.toFixed(1)} · ${t(labelKey)}`
- : value.toFixed(1);
+ ? `${fmt.number(value, 1)} · ${t(labelKey)}`
+ : fmt.number(value, 1);
}}
formatDate={(date) => formatDay(dateOnlyKey(date))}
bucket="day"
diff --git a/src/components/charts/workout-elevation-chart.tsx b/src/components/charts/workout-elevation-chart.tsx
index dbf0382fc..251cd6df5 100644
--- a/src/components/charts/workout-elevation-chart.tsx
+++ b/src/components/charts/workout-elevation-chart.tsx
@@ -10,7 +10,7 @@ import {
ResponsiveContainer,
} from "recharts";
-import { useTranslations } from "@/lib/i18n/context";
+import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import {
applyDisplayTransform,
@@ -35,6 +35,7 @@ export function WorkoutElevationChart({
points: WorkoutElevationPoint[];
}) {
const { t } = useTranslations();
+ const fmt = useFormatters();
const { preference } = useUnitDisplay();
// Distance along the route in km or mi, altitude in m or ft — the same
// registry the stat tiles read, so the axis and the tiles agree.
@@ -74,7 +75,7 @@ export function WorkoutElevationChart({
type="number"
domain={[0, "dataMax"]}
tick={{ fontSize: 10, fill: "var(--muted-foreground)" }}
- tickFormatter={(v) => Number(v).toFixed(1)}
+ tickFormatter={(v) => fmt.number(Number(v), 1)}
tickLine={false}
axisLine={false}
/>
@@ -94,7 +95,7 @@ export function WorkoutElevationChart({
fontSize: "0.875rem",
}}
labelFormatter={(d) =>
- `${Number(d).toFixed(2)} ${distance.displayUnit}`
+ `${fmt.number(Number(d), 2)} ${distance.displayUnit}`
}
formatter={(value) => [
`${Math.round(Number(value))} ${elevation.displayUnit}`,
diff --git a/src/components/custom-metrics/custom-metric-chart.tsx b/src/components/custom-metrics/custom-metric-chart.tsx
index e35a77dc8..0281fbd3c 100644
--- a/src/components/custom-metrics/custom-metric-chart.tsx
+++ b/src/components/custom-metrics/custom-metric-chart.tsx
@@ -121,7 +121,11 @@ export function CustomMetricChart({
focusFor: (index) => {
const point = points[index];
return point
- ? { label: name, value: formatMetricValue(point.value, decimals), unit }
+ ? {
+ label: name,
+ value: formatMetricValue(point.value, decimals, fmt.number),
+ unit,
+ }
: null;
},
});
@@ -187,7 +191,9 @@ export function CustomMetricChart({
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
width={44}
- tickFormatter={(v: number) => formatMetricValue(v, decimals)}
+ tickFormatter={(v: number) =>
+ formatMetricValue(v, decimals, fmt.number)
+ }
/>
{targetLow != null && targetHigh != null ? (
formatMetricValue(value, decimals)}
+ formatValue={(value) =>
+ formatMetricValue(value, decimals, fmt.number)
+ }
formatDate={(date) => fmt.dateShortSmartCalendar(date)}
bucket="day"
metricLabel={name}
diff --git a/src/components/custom-metrics/custom-metric-history-list.tsx b/src/components/custom-metrics/custom-metric-history-list.tsx
index 0c327ef13..5e2b60dfa 100644
--- a/src/components/custom-metrics/custom-metric-history-list.tsx
+++ b/src/components/custom-metrics/custom-metric-history-list.tsx
@@ -24,7 +24,7 @@ import { Textarea } from "@/components/ui/textarea";
import { useTableSort } from "@/hooks/use-table-sort";
import { ApiError, apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { formatDateShort } from "@/lib/format";
-import { useTranslations } from "@/lib/i18n/context";
+import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { formatMetricValue } from "./format-value";
@@ -64,6 +64,7 @@ export function CustomMetricHistoryList({
decimals: number | null;
}) {
const { t } = useTranslations();
+ const fmt = useFormatters();
const queryClient = useQueryClient();
const { sortBy, sortDir, toggleSort } = useTableSort({
@@ -242,7 +243,7 @@ export function CustomMetricHistoryList({
{ordered.map((r) => (
- {formatMetricValue(r.value, decimals)}
+ {formatMetricValue(r.value, decimals, fmt.number)}
{unit ? ` ${unit}` : ""}
diff --git a/src/components/custom-metrics/custom-metric-list.tsx b/src/components/custom-metrics/custom-metric-list.tsx
index 232326b3d..ca71bdf8c 100644
--- a/src/components/custom-metrics/custom-metric-list.tsx
+++ b/src/components/custom-metrics/custom-metric-list.tsx
@@ -15,7 +15,7 @@ import { SectionHeading } from "@/components/ui/section-heading";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet } from "@/lib/api/api-fetch";
import { formatDate } from "@/lib/format";
-import { useTranslations } from "@/lib/i18n/context";
+import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { CustomMetricForm } from "./custom-metric-form";
@@ -31,6 +31,7 @@ import type { CustomMetricDto, CustomMetricListResponse } from "./types";
*/
export function CustomMetricList() {
const { t } = useTranslations();
+ const fmt = useFormatters();
// v1.36.x — the section is dropped whole inside somebody else's record.
// Defining a metric is not a delegated verb, and every row here links to
// `/custom-metrics/{id}`, which sharing does not cover: the shell turns a
@@ -133,6 +134,7 @@ export function CustomMetricList() {
{formatMetricValue(
metric.latest.value,
metric.decimals,
+ fmt.number,
)}
{metric.unit ? ` ${metric.unit}` : ""}
diff --git a/src/components/custom-metrics/format-value.ts b/src/components/custom-metrics/format-value.ts
index 6ee795c99..69e7fd68f 100644
--- a/src/components/custom-metrics/format-value.ts
+++ b/src/components/custom-metrics/format-value.ts
@@ -1,17 +1,20 @@
/**
- * v1.25.5 — format a custom-metric value for display.
+ * Format a custom-metric value for display.
*
* When the metric defines a fixed number of decimals, honour it; otherwise fall
* back to a compact representation that trims trailing zeros (up to 3 places).
+ * The digits come from the caller's locale formatter (`useFormatters().number`),
+ * so a German reader sees "70,5" where an English one sees "70.5".
*/
export function formatMetricValue(
value: number,
decimals: number | null | undefined,
+ formatNumber: (value: number, fractionDigits?: number) => string,
): string {
if (!Number.isFinite(value)) return "—";
if (decimals != null) {
- return value.toFixed(decimals);
+ return formatNumber(value, decimals);
}
// Compact default: round to at most 3 places and drop trailing zeros.
- return String(Math.round(value * 1000) / 1000);
+ return formatNumber(Math.round(value * 1000) / 1000);
}
diff --git a/src/components/cycle/cycle-phase-crosstab.tsx b/src/components/cycle/cycle-phase-crosstab.tsx
index 9dd962d90..57433890b 100644
--- a/src/components/cycle/cycle-phase-crosstab.tsx
+++ b/src/components/cycle/cycle-phase-crosstab.tsx
@@ -1,6 +1,7 @@
"use client";
-import { useTranslations } from "@/lib/i18n/context";
+import { useFormatters, useTranslations } from "@/lib/i18n/context";
+import type { Formatters } from "@/lib/format-locale";
import { useUnitDisplay, type UnitDisplay } from "@/hooks/use-unit-display";
import {
applyDisplayTransform,
@@ -81,8 +82,12 @@ const CONFIDENCE_CLASS: Record = {
};
/** Whole numbers for steps; one decimal otherwise. */
-function fmt(value: number, display: CyclePhaseCrosstabDisplay): string {
- return display === "steps" ? Math.round(value).toString() : value.toFixed(1);
+function fmt(
+ value: number,
+ display: CyclePhaseCrosstabDisplay,
+ nf: Formatters,
+): string {
+ return display === "steps" ? nf.integer(value) : nf.number(value, 1);
}
/**
@@ -152,6 +157,7 @@ export function CyclePhaseHeadline({
headline: CyclePhaseCrosstabRow | null;
}) {
const { t } = useTranslations();
+ const nf = useFormatters();
const unitDisplay = useUnitDisplay();
if (!headline) {
return (
@@ -176,7 +182,7 @@ export function CyclePhaseHeadline({
const params = {
metric: metricLabel,
// A phase delta → factor-only conversion (no affine offset).
- delta: fmt(Math.abs(deltaConv(headline.delta)), headline.display),
+ delta: fmt(Math.abs(deltaConv(headline.delta)), headline.display, nf),
unit,
dir,
};
@@ -199,6 +205,7 @@ export function CyclePhaseCrosstab({
rows: CyclePhaseCrosstabRow[];
}) {
const { t } = useTranslations();
+ const nf = useFormatters();
const unitDisplay = useUnitDisplay();
if (rows.length === 0) return null;
@@ -223,7 +230,7 @@ export function CyclePhaseCrosstab({
// 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)} ${unit}`;
+ const deltaText = `${up ? "+" : ""}${fmt(deltaConv(row.delta), row.display, nf)} ${unit}`;
return (
{t("cycle.insights.crosstab.detail", {
metric: metricLabel,
- lutealAvg: fmt(absConv(row.lutealAvg), row.display),
- follicularAvg: fmt(absConv(row.follicularAvg), row.display),
+ lutealAvg: fmt(absConv(row.lutealAvg), row.display, nf),
+ follicularAvg: fmt(
+ absConv(row.follicularAvg),
+ row.display,
+ nf,
+ ),
unit,
lutealDays: row.lutealDays,
follicularDays: row.follicularDays,
diff --git a/src/components/illness/log-day-sheet.tsx b/src/components/illness/log-day-sheet.tsx
index 85b3e0c86..5284689ab 100644
--- a/src/components/illness/log-day-sheet.tsx
+++ b/src/components/illness/log-day-sheet.tsx
@@ -24,7 +24,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
-import { useTranslations } from "@/lib/i18n/context";
+import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog";
@@ -141,6 +141,7 @@ export function LogDaySheet({
onsetDate,
}: LogDaySheetProps) {
const { t } = useTranslations();
+ const fmt = useFormatters();
// The fever is stored in °C and typed in the reader's temperature unit:
// seeded through the transform, inverted on save.
const { toDisplay, fromDisplay, unitFor } = useUnitDisplay();
@@ -185,7 +186,8 @@ export function LogDaySheet({
setFunctionalImpact(dto?.functionalImpact ?? null);
setFever(
dto?.feverC != null
- ? String(toDisplay("BODY_TEMPERATURE", dto.feverC))
+ ? // Seeded in the reader's notation; the parser below takes either mark.
+ fmt.number(toDisplay("BODY_TEMPERATURE", dto.feverC))
: "",
);
setNote(dto?.note ?? "");
@@ -345,7 +347,7 @@ export function LogDaySheet({
summary={
feverValue != null ? (
- {feverValue.toFixed(1)} {feverUnit}
+ {fmt.number(feverValue, 1)} {feverUnit}
) : null
}
@@ -357,7 +359,7 @@ export function LogDaySheet({
setFever(e.target.value)}
className="max-w-32"
diff --git a/src/components/insights/ecg-waveform.tsx b/src/components/insights/ecg-waveform.tsx
index 3779c7642..2534d492e 100644
--- a/src/components/insights/ecg-waveform.tsx
+++ b/src/components/insights/ecg-waveform.tsx
@@ -83,6 +83,7 @@ function buildPath(samples: number[]): string {
const x = i * xStep;
// Higher voltage → smaller y (up).
const y = Y_PAD + ((max - samples[i]) / span) * usableH;
+ // eslint-disable-next-line healthlog/no-unlocalised-decimal -- SVG coordinates, read by the renderer, not a person
d += `${i === 0 ? "M" : "L"}${x.toFixed(2)} ${y.toFixed(2)}`;
if (i < n - 1) d += " ";
}
diff --git a/src/components/insights/metric-target-summary.tsx b/src/components/insights/metric-target-summary.tsx
index e0cc9789a..0e7d85126 100644
--- a/src/components/insights/metric-target-summary.tsx
+++ b/src/components/insights/metric-target-summary.tsx
@@ -7,7 +7,7 @@ import { ExternalLink, Target } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { queryKeys } from "@/lib/query-keys";
-import { useTranslations } from "@/lib/i18n/context";
+import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { resolveTargetUnitAdapter } from "@/lib/targets/target-unit-display";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { RangeBar } from "@/components/targets/range-bar";
@@ -231,6 +231,7 @@ function TargetReferencePanel({
glucoseUnit: payloadGlucoseUnit,
}: TargetReferencePanelProps) {
const { t } = useTranslations();
+ const fmt = useFormatters();
const adjust = useTargetAdjust();
const { preference, glucoseUnit: accountGlucoseUnit } = useUnitDisplay();
const glucoseUnit = payloadGlucoseUnit ?? accountGlucoseUnit;
@@ -314,7 +315,7 @@ function TargetReferencePanel({
const avgValue =
bpDiastolic?.average30 != null
? `${Math.round(average30)}/${Math.round(bpDiastolic.average30)}`
- : String(Math.round(average30 * 10) / 10);
+ : fmt.number(Math.round(average30 * 10) / 10);
averageLabel = `${t("targets.average30d")} ${avgValue} ${unit}`;
}
diff --git a/src/components/insights/mood/mood-narrative-feed.tsx b/src/components/insights/mood/mood-narrative-feed.tsx
index a916e5b12..8abccfd65 100644
--- a/src/components/insights/mood/mood-narrative-feed.tsx
+++ b/src/components/insights/mood/mood-narrative-feed.tsx
@@ -13,7 +13,7 @@ import {
} from "lucide-react";
import type { ComponentType } from "react";
-import { useTranslations } from "@/lib/i18n/context";
+import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
@@ -59,8 +59,12 @@ function pickIcon(item: MoodNarrativeItem): ComponentType<{
return ICONS[item.kind] ?? Tag;
}
+/** Vars carrying a one-decimal score figure (`mood-narratives.ts`). */
+const DECIMAL_VARS = ["delta", "value"] as const;
+
export function MoodNarrativeFeed({ items }: { items: MoodNarrativeItem[] }) {
const { t } = useTranslations();
+ const fmt = useFormatters();
if (items.length === 0) return null;
@@ -89,6 +93,13 @@ export function MoodNarrativeFeed({ items }: { items: MoodNarrativeItem[] }) {
if (vars.weekdayKey) vars.weekday = t(vars.weekdayKey);
if (vars.tagKey) vars.tag = t(vars.tagKey);
if (vars.bucketKey) vars.bucket = t(vars.bucketKey);
+ // The server spells the score figures with a full stop so they
+ // survive JSON unchanged; they are printed in the reader's notation.
+ for (const name of DECIMAL_VARS) {
+ const raw = vars[name];
+ const value = raw === undefined ? Number.NaN : Number(raw);
+ if (Number.isFinite(value)) vars[name] = fmt.number(value, 1);
+ }
return (
`${x.toFixed(1)},${y.toFixed(1)}`)
.join(" ");
const isValidRange =
diff --git a/src/components/settings/nutrient-intake-card.tsx b/src/components/settings/nutrient-intake-card.tsx
index 1bc12e5f0..652e07051 100644
--- a/src/components/settings/nutrient-intake-card.tsx
+++ b/src/components/settings/nutrient-intake-card.tsx
@@ -24,7 +24,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
-import { useTranslations } from "@/lib/i18n/context";
+import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
@@ -46,14 +46,13 @@ interface NutrientIntakeOverview {
nutrients: NutrientOverviewRow[];
}
-function formatAmount(amount: number): string {
- // Day totals arrive as floats; one decimal is plenty for a glance
- // list and trailing zeros read as noise.
- return String(Math.round(amount * 10) / 10);
-}
-
export function NutrientIntakeCard() {
const { t } = useTranslations();
+ const fmt = useFormatters();
+ // Day totals arrive as floats; one decimal is plenty for a glance list and
+ // trailing zeros read as noise, so the digit count is a ceiling.
+ const formatAmount = (amount: number) =>
+ fmt.number(Math.round(amount * 10) / 10);
const { user } = useAuth();
const enabled = user?.modules?.nutrients === true;
diff --git a/src/components/timeline/timeline-geometry.ts b/src/components/timeline/timeline-geometry.ts
index bb95c69c5..2bb2b49f4 100644
--- a/src/components/timeline/timeline-geometry.ts
+++ b/src/components/timeline/timeline-geometry.ts
@@ -1157,6 +1157,7 @@ export function layoutSeries(
thin: p.count < THIN_BUCKET_COUNT,
};
});
+ // eslint-disable-next-line healthlog/no-unlocalised-decimal -- SVG coordinates, read by the renderer, not a person
const xy = (p: SeriesPointLayout) => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`;
let path = "";
let bridges = "";
diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx
index f5fb159d5..0e79b7a11 100644
--- a/src/components/ui/calendar.tsx
+++ b/src/components/ui/calendar.tsx
@@ -197,6 +197,7 @@ function CalendarDayButton({
ref={ref}
variant="ghost"
size="icon"
+ // eslint-disable-next-line healthlog/no-unlocalised-decimal -- data attribute the day-picker's own styles key on, never shown
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
|