From 56daa573505338b2ade5b6dc7d8d2938b32fc7e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 12:55:06 +0200 Subject: [PATCH 01/20] refactor(format): one byte formatter for every surface The document vault and the admin area each carried a formatBytes; the same backup read 1.25 MB in one and 1.2 MB in the other, and only the vault knew gigabytes. The vault rule (one decimal below 100, up to GB, digits in the reader's locale) now lives in src/lib/format/bytes.ts and takes the locale, so the server-rendered share page uses it too. A test holds the definition to that one module. --- src/components/admin/backups-section.tsx | 6 +-- .../admin/measurement-maintenance-card.tsx | 18 ++----- .../admin/offhost-backups-section.tsx | 4 +- src/components/clinician/documents-list.tsx | 2 +- .../documents/__tests__/vault-utils.test.ts | 10 ---- src/components/documents/document-card.tsx | 7 +-- .../documents/document-detail-sheet.tsx | 2 +- .../sources/document-source-picker.tsx | 2 +- src/components/documents/upload-zone.tsx | 2 +- src/components/documents/vault-utils.ts | 15 ------ src/lib/format/__tests__/bytes.test.ts | 53 +++++++++++++++++++ src/lib/format/bytes.ts | 33 ++++++------ 12 files changed, 84 insertions(+), 70 deletions(-) create mode 100644 src/lib/format/__tests__/bytes.test.ts diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 16b6e5ed4..2f3ab6759 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -843,7 +843,7 @@ async function uploadOnce(file: File) { } export function BackupsSection() { - const { t } = useTranslations(); + const { t, locale } = useTranslations(); const fmt = useFormatters(); const queryClient = useQueryClient(); @@ -1327,7 +1327,7 @@ export function BackupsSection() { - {formatBytes(row.sizeBytes, fmt)} + {formatBytes(row.sizeBytes, locale)} {fmt.dateTime(row.createdAt)} @@ -1394,7 +1394,7 @@ export function BackupsSection() {

{t("admin.section.backups.colSize")}:{" "} - {formatBytes(row.sizeBytes, fmt)} ·{" "} + {formatBytes(row.sizeBytes, locale)} ·{" "} {fmt.dateTime(row.createdAt)}

diff --git a/src/components/admin/measurement-maintenance-card.tsx b/src/components/admin/measurement-maintenance-card.tsx index 6109f5a89..3569c0e64 100644 --- a/src/components/admin/measurement-maintenance-card.tsx +++ b/src/components/admin/measurement-maintenance-card.tsx @@ -45,20 +45,8 @@ const ENDPOINT = "/api/admin/maintenance/measurements"; /** How often the card asks while a run is waiting or working. */ const POLL_MS = 10_000; -const GIB = 1024 * 1024 * 1024; - -function sizeLabel( - bytes: number, - fmt: ReturnType, -): string { - // The shared formatter stops at MB; this table is routinely gigabytes. - return bytes >= GIB - ? `${fmt.number(bytes / GIB, 2)} GB` - : formatBytes(bytes, fmt); -} - export function MeasurementMaintenanceCard() { - const { t } = useTranslations(); + const { t, locale } = useTranslations(); const fmt = useFormatters(); const queryClient = useQueryClient(); @@ -132,11 +120,11 @@ export function MeasurementMaintenanceCard() {
) : null} diff --git a/src/components/admin/offhost-backups-section.tsx b/src/components/admin/offhost-backups-section.tsx index 99ffb6af2..3e65f6402 100644 --- a/src/components/admin/offhost-backups-section.tsx +++ b/src/components/admin/offhost-backups-section.tsx @@ -84,7 +84,7 @@ function freshnessLabel( } function AccountRow({ row }: { row: OffhostAccountRow }) { - const { t } = useTranslations(); + const { t, locale } = useTranslations(); const fmt = useFormatters(); return ( @@ -128,7 +128,7 @@ function AccountRow({ row }: { row: OffhostAccountRow }) { }) : `${fmt.dateTime(row.lastSuccessAt)} · ${formatBytes( row.sizeBytes, - fmt, + locale, )} · ${t("admin.section.backups.offhost.age", { hours: row.ageHours ?? 0, })}`} diff --git a/src/components/clinician/documents-list.tsx b/src/components/clinician/documents-list.tsx index b5850b2dd..d9bbc1317 100644 --- a/src/components/clinician/documents-list.tsx +++ b/src/components/clinician/documents-list.tsx @@ -11,7 +11,7 @@ import { Download } from "lucide-react"; import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta"; -import { formatBytes } from "@/components/documents/vault-utils"; +import { formatBytes } from "@/lib/format/bytes"; import type { ShareViewDocument } from "@/lib/clinician-share/share-view-data"; import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents"; diff --git a/src/components/documents/__tests__/vault-utils.test.ts b/src/components/documents/__tests__/vault-utils.test.ts index f368012c5..d881f7a5e 100644 --- a/src/components/documents/__tests__/vault-utils.test.ts +++ b/src/components/documents/__tests__/vault-utils.test.ts @@ -11,7 +11,6 @@ import { documentCardKeyAction, documentDateKey, expandRangeSelection, - formatBytes, formatMonthLabel, hasProcessingDocument, isDocumentProcessing, @@ -216,15 +215,6 @@ describe("timeline items", () => { }); }); -describe("byte formatting", () => { - it("scales units and keeps one decimal below 10", () => { - expect(formatBytes(0, "en")).toBe("0 B"); - expect(formatBytes(512, "en")).toBe("512 B"); - expect(formatBytes(26_214_400, "en")).toBe("25 MB"); - expect(formatBytes(1_288_490_189, "en")).toBe("1.2 GB"); - }); -}); - describe("upload response contract (§3.2)", () => { it("HTTP 200 + meta.duplicate returns the EXISTING row as a duplicate", () => { const body = JSON.stringify({ diff --git a/src/components/documents/document-card.tsx b/src/components/documents/document-card.tsx index d40019b92..807e551b0 100644 --- a/src/components/documents/document-card.tsx +++ b/src/components/documents/document-card.tsx @@ -31,11 +31,8 @@ import { cn } from "@/lib/utils"; import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; import { DOCUMENT_KIND_ICONS } from "./document-kind-meta"; import type { UploadQueueItem } from "./use-document-upload"; -import { - documentCardKeyAction, - formatBytes, - isDocumentProcessing, -} from "./vault-utils"; +import { formatBytes } from "@/lib/format/bytes"; +import { documentCardKeyAction, isDocumentProcessing } from "./vault-utils"; /** How long the "Ready" chip stays up after a card's index lands. */ const READY_FLASH_MS = 3000; diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index c5594528f..1426fcb91 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -102,7 +102,7 @@ import { useDocumentSummary, useSuggestDetails, } from "./use-document-assist"; -import { formatBytes } from "./vault-utils"; +import { formatBytes } from "@/lib/format/bytes"; import { QueryErrorRow } from "@/components/ui/query-error-row"; type PatchInput = { diff --git a/src/components/documents/sources/document-source-picker.tsx b/src/components/documents/sources/document-source-picker.tsx index 1ae4fc11d..ba50192d0 100644 --- a/src/components/documents/sources/document-source-picker.tsx +++ b/src/components/documents/sources/document-source-picker.tsx @@ -75,7 +75,7 @@ import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documen import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; import type { WrittenOutcome } from "@/lib/outcome/written-outcome"; -import { formatBytes } from "../vault-utils"; +import { formatBytes } from "@/lib/format/bytes"; import { errorCodeOf, isStoppingError, diff --git a/src/components/documents/upload-zone.tsx b/src/components/documents/upload-zone.tsx index c94863234..40386a4ba 100644 --- a/src/components/documents/upload-zone.tsx +++ b/src/components/documents/upload-zone.tsx @@ -19,7 +19,7 @@ import { type RefObject } from "react"; import { Progress } from "@/components/ui/progress"; import { useTranslations } from "@/lib/i18n/context"; import type { DocumentUsageDto } from "@/lib/validations/inbound-documents"; -import { formatBytes } from "./vault-utils"; +import { formatBytes } from "@/lib/format/bytes"; /** Usage fraction above which the quota bar appears. */ const QUOTA_BAR_THRESHOLD = 0.8; diff --git a/src/components/documents/vault-utils.ts b/src/components/documents/vault-utils.ts index 3e4c12175..e7c0f3653 100644 --- a/src/components/documents/vault-utils.ts +++ b/src/components/documents/vault-utils.ts @@ -302,21 +302,6 @@ export function formatMonthLabel(key: string, locale: string): string { }).format(new Date(`${key}-15T12:00:00.000Z`)); } -/** Locale byte formatting: 0 B / 412 KB / 1.2 MB / 2.4 GB. */ -export function formatBytes(bytes: number, locale: string): string { - const units = ["B", "KB", "MB", "GB"] as const; - let value = Math.max(0, bytes); - let unit = 0; - while (value >= 1024 && unit < units.length - 1) { - value /= 1024; - unit += 1; - } - const digits = unit === 0 || value >= 100 ? 0 : value >= 10 ? 1 : 1; - return `${new Intl.NumberFormat(locale, { - maximumFractionDigits: digits, - }).format(value)} ${units[unit]}`; -} - /** * Shift-click range selection over the timeline's visual order. Selects * every id between the last plainly-toggled anchor and the target diff --git a/src/lib/format/__tests__/bytes.test.ts b/src/lib/format/__tests__/bytes.test.ts new file mode 100644 index 000000000..0413d42c8 --- /dev/null +++ b/src/lib/format/__tests__/bytes.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +import { formatBytes } from "../bytes"; + +describe("formatBytes", () => { + it("scales units and keeps one decimal below 100", () => { + expect(formatBytes(0, "en")).toBe("0 B"); + expect(formatBytes(512, "en")).toBe("512 B"); + expect(formatBytes(1_310_720, "en")).toBe("1.3 MB"); + expect(formatBytes(26_214_400, "en")).toBe("25 MB"); + expect(formatBytes(1_288_490_189, "en")).toBe("1.2 GB"); + expect(formatBytes(150 * 1024 * 1024, "en")).toBe("150 MB"); + }); + + it("prints the digits in the reader's locale", () => { + expect(formatBytes(1_288_490_189, "de")).toBe("1,2 GB"); + expect(formatBytes(1_288_490_189, "de-DE")).toBe("1,2 GB"); + expect(formatBytes(1_310_720, "fr")).toBe("1,3 MB"); + }); + + it("never prints a negative size", () => { + expect(formatBytes(-5, "en")).toBe("0 B"); + }); +}); + +/** + * One byte formatter. A second definition is how the admin area and the + * document vault came to print the same size two ways. + */ +describe("a single byte formatter", () => { + it("is defined only in src/lib/format/bytes.ts", () => { + const root = join(__dirname, "../../.."); + const hits: string[] = []; + const walk = (dir: string) => { + for (const name of readdirSync(dir)) { + if (name === "node_modules" || name === "generated") continue; + const path = join(dir, name); + if (statSync(path).isDirectory()) { + walk(path); + } else if (/\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name)) { + const source = readFileSync(path, "utf8"); + if (/function\s+formatBytes\b|const\s+formatBytes\s*=/.test(source)) { + hits.push(path.slice(root.length + 1)); + } + } + } + }; + walk(root); + expect(hits).toEqual(["lib/format/bytes.ts"]); + }); +}); diff --git a/src/lib/format/bytes.ts b/src/lib/format/bytes.ts index c59732ce2..7fd753fbe 100644 --- a/src/lib/format/bytes.ts +++ b/src/lib/format/bytes.ts @@ -1,22 +1,23 @@ -import type { useFormatters } from "@/lib/i18n/context"; +import { getNumberFormat } from "@/lib/intl/formatter-cache"; /** - * One spelling of "1.25 MB" for every surface that shows a byte count from - * the admin backups response. + * One spelling of a byte count for every surface: 0 B / 412 KB / 1.2 MB / + * 2.4 GB, digits in the reader's locale ("1,2 MB" for a German reader). * - * It lives here rather than beside the card that happened to need it first: - * the second reader was importing a formatter out of a thousand-line client - * component, which is a dependency on a page's worth of code for four lines of - * arithmetic. Both cards render on the same route today, so it costs nothing - * today — and it becomes a cost the moment either one moves. + * There used to be two: this module printed megabytes with two decimals and + * no gigabyte step, the document vault printed one decimal up to gigabytes, so + * the same backup read "1.25 MB" in the admin area and "1.2 MB" beside a + * document. It takes the locale rather than a formatter object so the share + * page, which renders on the server without the formatter hook, uses it too. */ -export function formatBytes( - bytes: number, - fmt: ReturnType, -): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) { - return `${fmt.integer(Math.round(bytes / 1024))} KB`; +export function formatBytes(bytes: number, locale: string): string { + const units = ["B", "KB", "MB", "GB"] as const; + let value = Math.max(0, bytes); + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; } - return `${fmt.number(bytes / 1024 / 1024, 2)} MB`; + const digits = unit === 0 || value >= 100 ? 0 : 1; + return `${getNumberFormat(locale, { maximumFractionDigits: digits }).format(value)} ${units[unit]}`; } From 537bad4212bcefa0c0294beac651079743e32b42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 12:55:07 +0200 Subject: [PATCH 02/20] 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. --- .../__tests__/no-unlocalised-decimal.test.mjs | 177 +++++++++++ eslint-plugins/healthlog/index.js | 5 + .../healthlog/no-unlocalised-decimal.js | 291 ++++++++++++++++++ eslint.config.mjs | 7 + src/components/admin/encryption-section.tsx | 15 +- .../admin/general-settings-section.tsx | 3 +- src/components/admin/host-metrics-chart.tsx | 14 +- .../admin/user-management-section.tsx | 3 +- src/components/charts/mood-chart.tsx | 17 +- src/components/charts/mood-heatmap.tsx | 13 +- .../charts/workout-elevation-chart.tsx | 7 +- .../custom-metrics/custom-metric-chart.tsx | 16 +- .../custom-metric-history-list.tsx | 5 +- .../custom-metrics/custom-metric-list.tsx | 4 +- src/components/custom-metrics/format-value.ts | 9 +- src/components/cycle/cycle-phase-crosstab.tsx | 25 +- src/components/illness/log-day-sheet.tsx | 10 +- src/components/insights/ecg-waveform.tsx | 1 + .../insights/metric-target-summary.tsx | 5 +- .../insights/mood/mood-narrative-feed.tsx | 13 +- src/components/labs/lab-trend-sparkline.tsx | 1 + .../settings/nutrient-intake-card.tsx | 13 +- src/components/timeline/timeline-geometry.ts | 1 + src/components/ui/calendar.tsx | 1 + 24 files changed, 596 insertions(+), 60 deletions(-) create mode 100644 eslint-plugins/healthlog/__tests__/no-unlocalised-decimal.test.mjs create mode 100644 eslint-plugins/healthlog/no-unlocalised-decimal.js diff --git a/eslint-plugins/healthlog/__tests__/no-unlocalised-decimal.test.mjs b/eslint-plugins/healthlog/__tests__/no-unlocalised-decimal.test.mjs new file mode 100644 index 000000000..1ef5dd6e4 --- /dev/null +++ b/eslint-plugins/healthlog/__tests__/no-unlocalised-decimal.test.mjs @@ -0,0 +1,177 @@ +/** + * Unit tests for the `healthlog/no-unlocalised-decimal` rule — a displayed + * number goes through the app's formatters. Covers the four flagged idioms, + * the rounding round-trip that stays legal, the browser time zone lookup, the + * directive contract, and the scope gating. + */ + +import { describe, expect, it } from "vitest"; +import { Linter, RuleTester } from "eslint"; +import rule from "../no-unlocalised-decimal.js"; + +RuleTester.describe = describe; +RuleTester.it = it; +RuleTester.itOnly = it.only; + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2023, + sourceType: "module", + parserOptions: { ecmaFeatures: { jsx: true } }, + }, +}); + +const APP_FILE = "/repo/src/components/insights/example.tsx"; +const PAGE_FILE = "/repo/src/app/(app)/pulse/page.tsx"; + +ruleTester.run("no-unlocalised-decimal", rule, { + valid: [ + // The formatter is the sanctioned path. + { code: "const s = fmt.number(value, 1);", filename: APP_FILE }, + // Rounding, not display: the result is a number again. + { code: "const n = Number(value.toFixed(2));", filename: APP_FILE }, + { code: "const n = parseFloat(value.toFixed(2));", filename: APP_FILE }, + { + code: "const n = Number.parseFloat(value.toFixed(2));", + filename: APP_FILE, + }, + { code: "const n = +value.toFixed(2);", filename: APP_FILE }, + // A locale is passed. + { code: 'const s = n.toLocaleString("de-DE");', filename: APP_FILE }, + { + code: "const s = d.toLocaleDateString(locale, opts);", + filename: APP_FILE, + }, + { code: "const f = new Intl.NumberFormat(locale);", filename: APP_FILE }, + // The browser time zone lookup formats nothing. + { + code: "const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;", + filename: APP_FILE, + }, + // Integer arithmetic in text is fine. + { code: "const s = `${Math.round(a / b)} h`;", filename: APP_FILE }, + { code: "const s = String(Math.round(a / b));", filename: APP_FILE }, + // A tagged template is not text. + { code: "const q = sql`${a / b}`;", filename: APP_FILE }, + // Wire formats under src/app/api are not display. + { + code: "const s = value.toFixed(2);", + filename: "/repo/src/app/api/export/route.ts", + }, + // Outside the UI roots and in tests the rule is silent. + { code: "const s = value.toFixed(2);", filename: "/repo/src/lib/x.ts" }, + { + code: "const s = value.toFixed(2);", + filename: "/repo/src/components/__tests__/x.test.tsx", + }, + ], + invalid: [ + { + code: "const s = value.toFixed(1);", + filename: APP_FILE, + errors: [{ messageId: "fixed", data: { method: "toFixed" } }], + }, + { + code: "const s = `${value.toFixed(1)} kg`;", + filename: PAGE_FILE, + errors: [{ messageId: "fixed" }], + }, + { + code: "const s = value.toPrecision(3);", + filename: APP_FILE, + errors: [{ messageId: "fixed", data: { method: "toPrecision" } }], + }, + // Number() around something else does not launder the call. + { + code: "const s = String(Number(a) + value.toFixed(1));", + filename: APP_FILE, + errors: [{ messageId: "fixed" }], + }, + { + code: "{total.toLocaleString()}", + filename: APP_FILE, + errors: [{ messageId: "localeless" }], + }, + { + code: "const s = d.toLocaleDateString(undefined, { day: 'numeric' });", + filename: APP_FILE, + errors: [{ messageId: "localeless" }], + }, + { + code: "const s = d.toLocaleTimeString();", + filename: APP_FILE, + errors: [{ messageId: "localeless" }], + }, + { + code: "const f = new Intl.NumberFormat();", + filename: APP_FILE, + errors: [{ messageId: "intlLocaleless" }], + }, + { + code: "const f = new Intl.DateTimeFormat(undefined, { hour: '2-digit' });", + filename: APP_FILE, + errors: [{ messageId: "intlLocaleless" }], + }, + { + code: "const s = String(Math.round(v * 10) / 10);", + filename: APP_FILE, + errors: [{ messageId: "division" }], + }, + { + code: "const s = `${total / count} per day`;", + filename: APP_FILE, + errors: [{ messageId: "division" }], + }, + ], +}); + +/** + * Directives resolve against the rule's registered name, which RuleTester + * does not use, so they run through a Linter with the plugin registered as + * `healthlog` — the way the flat config loads it. + */ +describe("no-unlocalised-decimal disable directives", () => { + const linter = new Linter({ configType: "flat" }); + const config = [ + { + files: ["**/*.tsx"], + plugins: { healthlog: { rules: { "no-unlocalised-decimal": rule } } }, + rules: { "healthlog/no-unlocalised-decimal": "error" }, + languageOptions: { + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + }, + ]; + const lint = (code) => + linter + .verify(code, config, { filename: "src/components/charts/x.tsx" }) + .map((m) => m.messageId ?? m.message); + + it("flags the site without a directive", () => { + expect(lint("const d = `${x.toFixed(1)}`;")).toEqual(["fixed"]); + }); + + it("accepts a next-line directive that gives a reason", () => { + expect( + lint( + "// eslint-disable-next-line healthlog/no-unlocalised-decimal -- SVG coordinates, not text\nconst d = `${x.toFixed(1)}`;", + ), + ).toEqual([]); + }); + + it("refuses a directive without a reason", () => { + expect( + lint( + "// eslint-disable-next-line healthlog/no-unlocalised-decimal\nconst d = x.toFixed(1);", + ), + ).toEqual(["missingReason"]); + }); + + it("refuses a whole-file disable, which would hide the next site too", () => { + expect( + lint( + "const a = 1;\n/* eslint-disable healthlog/no-unlocalised-decimal -- SVG coordinates, not text */\nconst d = x.toFixed(1);", + ), + ).toEqual(["nextLineOnly"]); + }); +}); diff --git a/eslint-plugins/healthlog/index.js b/eslint-plugins/healthlog/index.js index 7205742f1..cf7c16ea5 100644 --- a/eslint-plugins/healthlog/index.js +++ b/eslint-plugins/healthlog/index.js @@ -16,6 +16,9 @@ * slicing an ISO string or parsing UTC midnight. * - `no-planning-tags` — no planning-note identifier at the head of a * comment line. + * - `no-unlocalised-decimal` — a displayed number goes through the app's + * formatters, not toFixed() or a locale-less + * toLocaleString(). */ "use strict"; @@ -29,6 +32,7 @@ const jobHandlerOutcome = require("./job-handler-outcome.js"); const noDefaultZoneLiteral = require("./no-default-zone-literal.js"); const noUtcDayKey = require("./no-utc-day-key.js"); const noPlanningTags = require("./no-planning-tags.js"); +const noUnlocalisedDecimal = require("./no-unlocalised-decimal.js"); module.exports = { rules: { @@ -46,5 +50,6 @@ module.exports = { "no-default-zone-literal": noDefaultZoneLiteral, "no-utc-day-key": noUtcDayKey, "no-planning-tags": noPlanningTags, + "no-unlocalised-decimal": noUnlocalisedDecimal, }, }; diff --git a/eslint-plugins/healthlog/no-unlocalised-decimal.js b/eslint-plugins/healthlog/no-unlocalised-decimal.js new file mode 100644 index 000000000..51bb3f2cf --- /dev/null +++ b/eslint-plugins/healthlog/no-unlocalised-decimal.js @@ -0,0 +1,291 @@ +/** + * @fileoverview ESLint rule — a number shown to a person goes through the + * app's locale, never through the JavaScript default. + * + * `value.toFixed(1)` always prints a full stop. A German, French, Italian, + * Spanish, Dutch or Polish reader expects a comma (`37,5 °C`), and the same + * screen already shows a comma wherever the value went through + * `useFormatters()`. The mixed reading is the defect: one card says `70,5 kg`, + * the card beside it `70.5 kg`. `toLocaleString()` without a locale is the + * mirror image of the same mistake — it follows the browser, not the language + * the person picked in HealthLog. + * + * The legitimate forms: + * + * - in a component: `const fmt = useFormatters(); fmt.number(value, 1)` + * (`fmt.integer`, `fmt.percent` for the other shapes) + * - outside React: `makeFormatters(locale, …)` from `@/lib/format-locale` + * - rounding, not display: `Number(value.toFixed(2))` stays legal — the + * result is a number again and is formatted wherever it is shown + * + * FLAGGED, in `src/components/` and `src/app/` outside `src/app/api/` (wire + * formats are not display) and outside test files: + * + * - `.toFixed(…)` and `.toPrecision(…)`, unless the call is the + * argument of `Number(…)` / `parseFloat(…)` / `Number.parseFloat(…)` or + * the operand of a unary `+` + * - `.toLocaleString()`, `toLocaleDateString()`, `toLocaleTimeString()` + * called with no locale or with `undefined` as the locale + * - `Intl.NumberFormat()` / `Intl.DateTimeFormat()` constructed with no + * locale or with `undefined` (reading `.resolvedOptions()` off it, the + * browser time zone lookup, formats nothing and passes) + * - a division rendered straight into text: `String(a / b)` and + * `` `${a / b}` `` — the quotient is fractional and prints with a stop + * + * A site where the stop is the point (SVG path data, a value written into a + * machine-read attribute) stays, with the reason on the line above: + * + * // eslint-disable-next-line healthlog/no-unlocalised-decimal -- + * + * The rule refuses a directive for itself without a reason, or in any form + * other than `eslint-disable-next-line`. + * + * WHAT THIS RULE DOES NOT CATCH + * + * Without type information it cannot tell a number from a string, so + * `String(weight)` or `` `${weight} kg` `` with a fractional `weight` passes, + * as does a quotient assigned to a variable first and rendered later. It + * removes the idioms that print a stop by construction, not every path to + * one. + */ + +"use strict"; + +const RULE_NAME = "healthlog/no-unlocalised-decimal"; +const ENFORCED_ROOTS = ["src/components/", "src/app/"]; +const EXEMPT_ROOTS = ["src/app/api/"]; +const MIN_REASON_CHARS = 8; + +const FIXED_METHODS = new Set(["toFixed", "toPrecision"]); +const LOCALE_METHODS = new Set([ + "toLocaleString", + "toLocaleDateString", + "toLocaleTimeString", +]); +const INTL_CTORS = new Set(["NumberFormat", "DateTimeFormat"]); + +function toPosix(filename) { + return filename.replace(/\\/g, "/"); +} + +function isTestFile(posix) { + return ( + /\.test\.[cm]?[jt]sx?$/.test(posix) || + /\.spec\.[cm]?[jt]sx?$/.test(posix) || + posix.includes("/__tests__/") || + posix.includes("/__mocks__/") + ); +} + +function isEnforced(filename) { + const posix = toPosix(filename); + if (!ENFORCED_ROOTS.some((root) => posix.includes(root))) return false; + if (EXEMPT_ROOTS.some((root) => posix.includes(root))) return false; + if (isTestFile(posix)) return false; + return true; +} + +function propertyName(member) { + if (!member || member.type !== "MemberExpression") return null; + if (!member.computed && member.property.type === "Identifier") { + return member.property.name; + } + if (member.computed && member.property.type === "Literal") { + return String(member.property.value); + } + return null; +} + +function isUndefined(node) { + return ( + !node || + (node.type === "Identifier" && node.name === "undefined") || + (node.type === "UnaryExpression" && node.operator === "void") + ); +} + +/** Strip TS-only wrappers and parentheses-equivalents the parser keeps. */ +function unwrap(node) { + let current = node; + while ( + current && + (current.type === "TSAsExpression" || + current.type === "TSNonNullExpression" || + current.type === "TSSatisfiesExpression" || + current.type === "ChainExpression") + ) { + current = current.expression; + } + return current; +} + +/** The call's value is turned back into a number: rounding, not display. */ +function isNumericRoundTrip(callNode) { + let child = callNode; + let parent = callNode.parent; + while ( + parent && + (parent.type === "TSAsExpression" || + parent.type === "TSNonNullExpression" || + parent.type === "ChainExpression") + ) { + child = parent; + parent = parent.parent; + } + if (!parent) return false; + if (parent.type === "UnaryExpression" && parent.operator === "+") { + return true; + } + if (parent.type !== "CallExpression" || parent.arguments[0] !== child) { + return false; + } + const callee = parent.callee; + if (callee.type === "Identifier") { + return callee.name === "Number" || callee.name === "parseFloat"; + } + return ( + callee.type === "MemberExpression" && + callee.object.type === "Identifier" && + callee.object.name === "Number" && + propertyName(callee) === "parseFloat" + ); +} + +function isDivision(node) { + const inner = unwrap(node); + return !!inner && inner.type === "BinaryExpression" && inner.operator === "/"; +} + +function isIntlCtor(callee) { + return ( + callee && + callee.type === "MemberExpression" && + callee.object.type === "Identifier" && + callee.object.name === "Intl" && + INTL_CTORS.has(propertyName(callee)) + ); +} + +/** @type {import("eslint").Rule.RuleModule} */ +const noUnlocalisedDecimalRule = { + meta: { + type: "problem", + docs: { + description: + "Format a displayed number through useFormatters() / makeFormatters(), not toFixed() or a locale-less toLocaleString().", + }, + schema: [], + messages: { + fixed: + "`{{method}}()` prints a full stop in every language. Format the value with `useFormatters().number(value, digits)` (or `makeFormatters` outside React). For rounding, wrap it in `Number(…)`. If the stop is meant, say why: `// eslint-disable-next-line healthlog/no-unlocalised-decimal -- `.", + localeless: + "`{{method}}()` without a locale follows the browser, not the language picked in HealthLog. Use `useFormatters()` or pass the active locale.", + intlLocaleless: + "`Intl.{{ctor}}` without a locale follows the browser, not the language picked in HealthLog. Use `useFormatters()` or pass the active locale.", + division: + "A quotient rendered straight into text prints with a full stop. Format it with `useFormatters().number(value, digits)`.", + missingReason: + "A disable directive for healthlog/no-unlocalised-decimal must say why the full stop is right here: `-- ` after the rule name.", + nextLineOnly: + "Disable healthlog/no-unlocalised-decimal only with `eslint-disable-next-line`, one site at a time, so the next occurrence is still caught.", + }, + }, + create(context) { + const filename = context.filename ?? context.getFilename?.(); + if (!filename || !isEnforced(filename)) { + return {}; + } + const sourceCode = context.sourceCode ?? context.getSourceCode(); + + function checkDirectives() { + for (const comment of sourceCode.getAllComments()) { + const text = comment.value.trim(); + const match = /^eslint-disable(-next-line|-line)?\b([\s\S]*)$/.exec( + text, + ); + if (!match || !match[2].includes(RULE_NAME)) continue; + if (match[1] !== "-next-line") { + context.report({ + loc: { line: 1, column: 0 }, + messageId: "nextLineOnly", + }); + continue; + } + const reason = match[2].split(/\s--\s/)[1]; + if (!reason || reason.trim().length < MIN_REASON_CHARS) { + context.report({ loc: comment.loc, messageId: "missingReason" }); + } + } + } + + function checkIntl(node) { + // `Intl.DateTimeFormat().resolvedOptions().timeZone` reads the + // browser's zone; nothing is formatted, so the missing locale is right. + const readsOptionsOnly = + node.parent && + node.parent.type === "MemberExpression" && + node.parent.object === node && + propertyName(node.parent) === "resolvedOptions"; + if ( + !readsOptionsOnly && + isIntlCtor(node.callee) && + isUndefined(node.arguments[0]) + ) { + context.report({ + node, + messageId: "intlLocaleless", + data: { ctor: propertyName(node.callee) }, + }); + } + } + + return { + "Program:exit": checkDirectives, + CallExpression(node) { + const name = propertyName(node.callee); + if (name && FIXED_METHODS.has(name)) { + if (!isNumericRoundTrip(node)) { + context.report({ + node, + messageId: "fixed", + data: { method: name }, + }); + } + return; + } + if (name && LOCALE_METHODS.has(name)) { + if (isUndefined(node.arguments[0])) { + context.report({ + node, + messageId: "localeless", + data: { method: name }, + }); + } + return; + } + if ( + node.callee.type === "Identifier" && + node.callee.name === "String" && + isDivision(node.arguments[0]) + ) { + context.report({ node, messageId: "division" }); + return; + } + checkIntl(node); + }, + NewExpression: checkIntl, + TemplateLiteral(node) { + if (node.parent && node.parent.type === "TaggedTemplateExpression") { + return; + } + for (const expression of node.expressions) { + if (isDivision(expression)) { + context.report({ node: expression, messageId: "division" }); + } + } + }, + }; + }, +}; + +module.exports = noUnlocalisedDecimalRule; diff --git a/eslint.config.mjs b/eslint.config.mjs index 3f53b7b7d..b4e514c25 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -121,6 +121,13 @@ const eslintConfig = defineConfig([ // scripts/strip-planning-tags.mjs; see eslint-plugins/healthlog/ // planning-tags.js for the shape and its limits. "healthlog/no-planning-tags": "error", + // A number shown to a person goes through useFormatters() (or + // makeFormatters outside React): toFixed() prints a full stop in every + // language and a locale-less toLocaleString() follows the browser + // instead of the language picked in the app. SVG path data and other + // machine-read strings carry `eslint-disable-next-line ... -- `; + // 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 && From ce8074ad089c325d6c2d6bff86f16955de07e098 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 13:03:12 +0200 Subject: [PATCH 03/20] fix(ui): keep a wrapped label's colon on its last word The label primitive was an inline flex box, so its ::after colon was a flex item of its own: when a long field name wrapped in a narrow column, the colon floated beside the whole text block instead of following the last word. The label now lays out inline and the colon wraps with the text. Call sites that set flex themselves keep their own layout. --- src/components/ui/__tests__/label.test.tsx | 36 ++++++++++++++++++++++ src/components/ui/label.tsx | 13 +++++++- 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 src/components/ui/__tests__/label.test.tsx diff --git a/src/components/ui/__tests__/label.test.tsx b/src/components/ui/__tests__/label.test.tsx new file mode 100644 index 000000000..b2d12d8e7 --- /dev/null +++ b/src/components/ui/__tests__/label.test.tsx @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { Label } from "../label"; + +describe("
  • {t("settings.ai.deviceCodeStep3")}
  • diff --git a/src/components/admin/module-availability-section.tsx b/src/components/admin/module-availability-section.tsx index df5442811..f57122c64 100644 --- a/src/components/admin/module-availability-section.tsx +++ b/src/components/admin/module-availability-section.tsx @@ -23,6 +23,7 @@ import { import type { ModuleKey } from "@/lib/modules/registry"; import { SettingsToggle } from "./_shared"; import { apiGet, apiPatch } from "@/lib/api/api-fetch"; +import { InlineAction } from "@/components/ui/inline-action"; /** * v1.18.0 — operator-side panel for server-wide module availability, the @@ -127,12 +128,11 @@ export function ModuleAvailabilitySection() { onCheckedChange={() => {}} disabled /> - - {t("admin.section.coach.title")} - + + + {t("admin.section.coach.title")} + + ); } diff --git a/src/components/admin/recent-audit-preview.tsx b/src/components/admin/recent-audit-preview.tsx index cab8589b7..0fb5ac6e4 100644 --- a/src/components/admin/recent-audit-preview.tsx +++ b/src/components/admin/recent-audit-preview.tsx @@ -27,6 +27,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { type AdminAuditEntry, useAuthActionLabels } from "./_shared"; import { apiGet } from "@/lib/api/api-fetch"; +import { InlineAction } from "@/components/ui/inline-action"; const PREVIEW_LIMIT = 10; @@ -61,13 +62,12 @@ export function RecentAuditPreview() { titleId="admin-overview-audit-heading" title={t("admin.overview.auditTitle")} status={ - - {t("admin.overview.auditViewAll")} -
    {sourceLink ? ( - - - {t("targets.sourceLabel", { - source: sourceName(target.source, t), - })} - - + + + + {t("targets.sourceLabel", { + source: sourceName(target.source, t), + })} + + + ) : ( {t("targets.sourceLabel", { diff --git a/src/components/insights/sleep/chronotype-card.tsx b/src/components/insights/sleep/chronotype-card.tsx index 14c0e66be..3220acd6c 100644 --- a/src/components/insights/sleep/chronotype-card.tsx +++ b/src/components/insights/sleep/chronotype-card.tsx @@ -8,6 +8,7 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { LearningGate } from "@/components/ui/learning-gate"; import { TileHeader } from "@/components/insights/tile-header"; import type { ChronotypeDto } from "./use-sleep-rhythm"; +import { InlineAction } from "@/components/ui/inline-action"; /** Minutes-of-day → "HH:MM" wall-clock label (24 h, zero-padded). */ function clockLabel(minutesOfDay: number): string { @@ -90,12 +91,12 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) { )}
    - + {showAdvanced && (
    diff --git a/src/components/insights/trends-row.tsx b/src/components/insights/trends-row.tsx index b62e8e06b..eda495290 100644 --- a/src/components/insights/trends-row.tsx +++ b/src/components/insights/trends-row.tsx @@ -23,6 +23,7 @@ import { type TrendAnnotationStatus, } from "./trend-annotation"; import { useUnitDisplay } from "@/hooks/use-unit-display"; +import { InlineAction } from "@/components/ui/inline-action"; /** * Trends row — a small chart per metric above a one-sentence @@ -339,15 +340,20 @@ export function TrendsRow({ is used but was unreachable from Insights. The link sits below the caption so the chart keeps its own interactions. */} {config.detailHref ? ( - - {t("insights.trendsRow.viewDetail", { metric: title })} -
    ); diff --git a/src/components/insights/workout-detail/stats.tsx b/src/components/insights/workout-detail/stats.tsx index 36002d3bd..4aa93b56c 100644 --- a/src/components/insights/workout-detail/stats.tsx +++ b/src/components/insights/workout-detail/stats.tsx @@ -28,6 +28,7 @@ import { formatPace, formatDurationMinutes, } from "./format"; +import { InlineAction } from "@/components/ui/inline-action"; interface StatTileProps { icon: ReactNode; @@ -199,14 +200,15 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) { )} {workout.previousWorkoutId ? ( - - {t("insights.workouts.detail.previousSessionLink")} - - + ) : null}
      diff --git a/src/components/medications/card-parts/medication-compliance-bars.tsx b/src/components/medications/card-parts/medication-compliance-bars.tsx index 2f70c9964..99bd9fe02 100644 --- a/src/components/medications/card-parts/medication-compliance-bars.tsx +++ b/src/components/medications/card-parts/medication-compliance-bars.tsx @@ -3,6 +3,7 @@ import { Flame, RotateCw } from "lucide-react"; import { ComplianceInfoTip } from "@/components/medications/card-parts/compliance-info-tip"; import { Progress } from "@/components/ui/progress"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { InlineAction } from "@/components/ui/inline-action"; interface MedicationComplianceBarsProps { rate7: number; @@ -286,14 +287,10 @@ export function MedicationComplianceError({
      - +
      diff --git a/src/components/medications/detail/medication-detail-tabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx index bb34d5411..2f0128495 100644 --- a/src/components/medications/detail/medication-detail-tabs.tsx +++ b/src/components/medications/detail/medication-detail-tabs.tsx @@ -104,6 +104,7 @@ import { AutoMarkTakenBody } from "@/components/medications/sections/auto-mark-t import { IntakeTrackingBody } from "@/components/medications/sections/intake-tracking-section"; import { MedicationRelatedSection } from "@/components/medications/detail/medication-related-section"; import type { RelatedLink } from "@/lib/links/related"; +import { InlineAction } from "@/components/ui/inline-action"; // v1.15.20 — Recharts stays out of the detail page's initial bundle: // both chart bodies load through `next/dynamic` (client-only) with the @@ -1097,14 +1098,13 @@ function StatusRow({

      {label}

      {value}

      - + ); } diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx index 7af96d638..1bab5744a 100644 --- a/src/components/medications/sections/inventory-section.tsx +++ b/src/components/medications/sections/inventory-section.tsx @@ -79,6 +79,7 @@ import { type InventoryItem, type InventoryState, } from "./inventory-shared"; +import { InlineAction } from "@/components/ui/inline-action"; export { AddInventoryDialog, AdjustInventoryDialog } from "./inventory-dialogs"; export { invalidateSupplyQueries } from "./inventory-shared"; @@ -314,17 +315,18 @@ export function InventorySection({ of what was shared" panel. */} {!inSharedRecord && (
      - - {lowStockDays !== null - ? t("medications.detail.bestand.lowStockLinkOn", { - days: lowStockDays, - }) - : t("medications.detail.bestand.lowStockLinkOff")} - + + + {lowStockDays !== null + ? t("medications.detail.bestand.lowStockLinkOn", { + days: lowStockDays, + }) + : t("medications.detail.bestand.lowStockLinkOff")} + +
      )} diff --git a/src/components/mood/mood-context-fields.tsx b/src/components/mood/mood-context-fields.tsx index 533e04384..35f587b64 100644 --- a/src/components/mood/mood-context-fields.tsx +++ b/src/components/mood/mood-context-fields.tsx @@ -55,6 +55,7 @@ import { contextValueLabelKey, } from "@/lib/mood/context-vocabulary"; import type { LinkedDayContext, LinkedFigure } from "@/lib/mood/linked-context"; +import { InlineAction } from "@/components/ui/inline-action"; const CONTEXT_NOTE_MAX_LENGTH = 500; @@ -683,16 +684,17 @@ export function MoodLinkedDaySection({