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 &&