fix(ui): print every displayed decimal in the reader's notation

toFixed() prints a full stop in every language and a locale-less
toLocaleString() follows the browser, so a German reader saw 37.5 next
to 70,5 on the same screen. Fever in the illness log, the cycle phase
crosstab, custom metric values, nutrient day totals, the mood chart and
heatmap tooltips, the mood takeaways, the elevation chart axis, the BP
30-day average, the host metrics clock and the encryption row counts now
go through useFormatters().

The new healthlog/no-unlocalised-decimal rule refuses toFixed(),
toPrecision(), a locale-less toLocaleString() or Intl constructor, and a
quotient rendered straight into text under src/components and src/app.
Rounding through Number(...) stays legal; SVG path data and number-input
values carry a reasoned next-line directive.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 12:55:07 +02:00
1 parent 56daa57350
commit 537bad4212
24 files changed
+596 -60

No files matched your search

+6 -9
View File
@@ -229,11 +229,11 @@ export function EncryptionSection() {
/>
<Stat
label={t("admin.section.encryption.totalRows")}
value={s.totalRows.toLocaleString()}
value={fmt.integer(s.totalRows)}
/>
<Stat
label={t("admin.section.encryption.staleRows")}
value={s.staleRows.toLocaleString()}
value={fmt.integer(s.staleRows)}
/>
</div>
{/* "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({
<td className="py-2 pr-3 font-mono text-xs">
{c.model}.{c.field}
</td>
<td className="py-2 pr-3">
{c.total.toLocaleString()}
</td>
<td className="py-2 pr-3">
{active.toLocaleString()}
</td>
<td className="py-2 pr-3">{fmt.integer(c.total)}</td>
<td className="py-2 pr-3">{fmt.integer(active)}</td>
<td className="py-2">
{stale > 0 ? (
<Badge variant="secondary">
{stale.toLocaleString()}
{fmt.integer(stale)}
</Badge>
) : (
<span className="text-muted-foreground">0</span>
@@ -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 = () => {
+9 -5
View File
@@ -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
@@ -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),
);
}