From 6182d4535435db9ff1de03e0adac457ca6f25edd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 16:52:08 +0200 Subject: [PATCH] fix(i18n): print server-built sentences in the reader's notation The decimal lint covered components and app only, and sentences for people are built in src/lib too: the weekday weight correlation printed "0.8 kg" into a German sentence, and the no-key fallback and the period narrative's fallback spelled fractions with a full stop. Those three use the new formatDecimal from format-locale with the reader's locale. The rule now covers src/lib and src/hooks; text for the model, operator log lines, SVG data, zone checks and wire values a client re-formats carry a reasoned directive. --- .../__tests__/no-unlocalised-decimal.test.mjs | 20 ++++++++++++++++-- .../healthlog/no-unlocalised-decimal.js | 17 ++++++++++++--- eslint.config.mjs | 3 ++- src/lib/ai/prompts/insight-system-prompt.ts | 10 ++++++--- src/lib/ai/prompts/shared-contracts.ts | 2 ++ src/lib/analytics/classifications.ts | 2 ++ src/lib/analytics/correlations-fast-path.ts | 1 + src/lib/analytics/score/adiposity.ts | 1 + src/lib/day/records.ts | 1 + src/lib/export/account-settings-backup.ts | 1 + src/lib/export/backup-blob.ts | 1 + src/lib/format-locale.ts | 17 +++++++++++++++ src/lib/import/unzip-export-xml.ts | 2 ++ .../insights/__tests__/correlations.test.ts | 21 +++++++++++++++++++ src/lib/insights/assessment-context.ts | 1 + src/lib/insights/correlations.ts | 7 +++++-- src/lib/insights/mood-narratives.ts | 7 +++++++ .../period-narrative-deterministic.ts | 12 ++++------- src/lib/insights/no-key-fallbacks.ts | 21 +++++++++++-------- src/lib/jobs/job-failures.ts | 3 ++- src/lib/jobs/offhost-backup.ts | 1 + src/lib/labs/value-update.ts | 1 + src/lib/record-settings/configuration.ts | 1 + src/lib/workouts/route-svg.ts | 2 ++ 24 files changed, 126 insertions(+), 29 deletions(-) diff --git a/eslint-plugins/healthlog/__tests__/no-unlocalised-decimal.test.mjs b/eslint-plugins/healthlog/__tests__/no-unlocalised-decimal.test.mjs index 1ef5dd6e4..89b164039 100644 --- a/eslint-plugins/healthlog/__tests__/no-unlocalised-decimal.test.mjs +++ b/eslint-plugins/healthlog/__tests__/no-unlocalised-decimal.test.mjs @@ -58,8 +58,12 @@ ruleTester.run("no-unlocalised-decimal", rule, { 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" }, + // Outside the source roots and in tests the rule is silent. + { code: "const s = value.toFixed(2);", filename: "/repo/scripts/x.ts" }, + { + code: "const s = value.toFixed(2);", + filename: "/repo/src/lib/__tests__/x.test.ts", + }, { code: "const s = value.toFixed(2);", filename: "/repo/src/components/__tests__/x.test.tsx", @@ -71,6 +75,18 @@ ruleTester.run("no-unlocalised-decimal", rule, { filename: APP_FILE, errors: [{ messageId: "fixed", data: { method: "toFixed" } }], }, + // A sentence built on the server for a person: the weekday correlation + // printed "0.8 kg" in a German sentence from src/lib. + { + code: 'const delta = Math.abs(a - b).toFixed(1); t("x", { delta });', + filename: "/repo/src/lib/insights/correlations.ts", + errors: [{ messageId: "fixed" }], + }, + { + code: "const s = value.toFixed(1);", + filename: "/repo/src/hooks/use-x.ts", + errors: [{ messageId: "fixed" }], + }, { code: "const s = `${value.toFixed(1)} kg`;", filename: PAGE_FILE, diff --git a/eslint-plugins/healthlog/no-unlocalised-decimal.js b/eslint-plugins/healthlog/no-unlocalised-decimal.js index 51bb3f2cf..167a18009 100644 --- a/eslint-plugins/healthlog/no-unlocalised-decimal.js +++ b/eslint-plugins/healthlog/no-unlocalised-decimal.js @@ -18,8 +18,14 @@ * - 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: + * FLAGGED, in `src/components/`, `src/app/`, `src/hooks/` and `src/lib/` + * outside `src/app/api/` (wire formats are not display) and outside test + * files. `src/lib/` is in scope because sentences for people are built + * there too (a correlation hypothesis, a deterministic fallback, a narrative) + * and the weekday correlation printed "0.8 kg" into a German sentence; a + * machine-read string there (a prompt for the model, an operator log line, + * SVG path data, a wire value a client re-formats) carries a reasoned + * directive like any other. * * - `.toFixed(…)` and `.toPrecision(…)`, unless the call is the * argument of `Number(…)` / `parseFloat(…)` / `Number.parseFloat(…)` or @@ -52,7 +58,12 @@ "use strict"; const RULE_NAME = "healthlog/no-unlocalised-decimal"; -const ENFORCED_ROOTS = ["src/components/", "src/app/"]; +const ENFORCED_ROOTS = [ + "src/components/", + "src/app/", + "src/hooks/", + "src/lib/", +]; const EXEMPT_ROOTS = ["src/app/api/"]; const MIN_REASON_CHARS = 8; diff --git a/eslint.config.mjs b/eslint.config.mjs index c6aabb847..a6bf8aac0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -122,7 +122,8 @@ const eslintConfig = defineConfig([ // 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 + // makeFormatters / formatDecimal outside React), in src/components, + // src/app, src/hooks and src/lib: 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 ... -- `; diff --git a/src/lib/ai/prompts/insight-system-prompt.ts b/src/lib/ai/prompts/insight-system-prompt.ts index 75f8351d9..bd9c64675 100644 --- a/src/lib/ai/prompts/insight-system-prompt.ts +++ b/src/lib/ai/prompts/insight-system-prompt.ts @@ -106,18 +106,22 @@ export function buildComparisonBlock( return `- ${metric.type}: no prior-period data available`; } const sign = metric.delta > 0 ? "+" : metric.delta < 0 ? "-" : "±"; + // eslint-disable-next-line healthlog/no-unlocalised-decimal -- text for the model, never shown to a person const formattedDelta = `${sign}${Math.abs(metric.delta).toFixed(1)}${metric.unit ? ` ${metric.unit}` : ""}`; const pct = metric.deltaPercent != null - ? ` (${metric.deltaPercent > 0 ? "+" : metric.deltaPercent < 0 ? "-" : "±"}${Math.abs(metric.deltaPercent).toFixed(1)} %)` + ? // eslint-disable-next-line healthlog/no-unlocalised-decimal -- text for the model, never shown to a person + ` (${metric.deltaPercent > 0 ? "+" : metric.deltaPercent < 0 ? "-" : "±"}${Math.abs(metric.deltaPercent).toFixed(1)} %)` : ""; const current = metric.currentAvg != null - ? `${metric.currentAvg.toFixed(1)}${metric.unit ? ` ${metric.unit}` : ""}` + ? // eslint-disable-next-line healthlog/no-unlocalised-decimal -- text for the model, never shown to a person + `${metric.currentAvg.toFixed(1)}${metric.unit ? ` ${metric.unit}` : ""}` : "n/a"; const prior = metric.baselineAvg != null - ? `${metric.baselineAvg.toFixed(1)}${metric.unit ? ` ${metric.unit}` : ""}` + ? // eslint-disable-next-line healthlog/no-unlocalised-decimal -- text for the model, never shown to a person + `${metric.baselineAvg.toFixed(1)}${metric.unit ? ` ${metric.unit}` : ""}` : "n/a"; return `- ${metric.type}: current ${current}, baseline ${prior}, delta ${formattedDelta}${pct}`; }; diff --git a/src/lib/ai/prompts/shared-contracts.ts b/src/lib/ai/prompts/shared-contracts.ts index c80b08acb..ed957ea59 100644 --- a/src/lib/ai/prompts/shared-contracts.ts +++ b/src/lib/ai/prompts/shared-contracts.ts @@ -57,12 +57,14 @@ function deNum(value: number): string { * that only names 54 mg/dL cannot fire on it. */ function glucoseFloor(mgdl: number, locale: ContractLocale): string { + // eslint-disable-next-line healthlog/no-unlocalised-decimal -- text for the model, never shown to a person const mmol = mgdlToMmol(mgdl).toFixed(1); return `${mgdl} mg/dL (${locale === "de" ? mmol.replace(".", ",") : mmol} mmol/L)`; } /** A temperature floor in both scales, °C first (the scale it is defined in). */ function temperatureFloor(celsius: number, locale: ContractLocale): string { + // eslint-disable-next-line healthlog/no-unlocalised-decimal -- text for the model, never shown to a person const fahrenheit = (celsius * 1.8 + 32).toFixed(1); return locale === "de" ? `${deNum(celsius)} °C (${fahrenheit.replace(".", ",")} °F)` diff --git a/src/lib/analytics/classifications.ts b/src/lib/analytics/classifications.ts index 773d20872..7f9843d8e 100644 --- a/src/lib/analytics/classifications.ts +++ b/src/lib/analytics/classifications.ts @@ -486,12 +486,14 @@ export function generateAlerts(input: AlertInput): HealthAlert[] { alerts.push({ level: "info", title: "Weight increasing", + // eslint-disable-next-line healthlog/no-unlocalised-decimal -- English API alert text that no screen renders message: `Your weight is increasing by approx. ${weeklyChange.toFixed(1)} kg per week over the last 30 days.`, }); } else if (weeklyChange < -0.5) { alerts.push({ level: "info", title: "Weight decreasing", + // eslint-disable-next-line healthlog/no-unlocalised-decimal -- English API alert text that no screen renders message: `Your weight is decreasing by approx. ${Math.abs(weeklyChange).toFixed(1)} kg per week over the last 30 days.`, }); } diff --git a/src/lib/analytics/correlations-fast-path.ts b/src/lib/analytics/correlations-fast-path.ts index 80cee2633..f0c9d9e8d 100644 --- a/src/lib/analytics/correlations-fast-path.ts +++ b/src/lib/analytics/correlations-fast-path.ts @@ -359,6 +359,7 @@ export async function computeCorrelationHypothesesFastPath( const weightWeekday = correlateWeightWeekday( { daily: weightWeekdayPairs }, t, + input.locale ?? defaultLocale, ); annotate({ diff --git a/src/lib/analytics/score/adiposity.ts b/src/lib/analytics/score/adiposity.ts index 6f0d370e3..53b73c071 100644 --- a/src/lib/analytics/score/adiposity.ts +++ b/src/lib/analytics/score/adiposity.ts @@ -81,6 +81,7 @@ export function computeAdiposityPillar( observed: { value: roundedRatio, unit: "ratio", + // eslint-disable-next-line healthlog/no-unlocalised-decimal -- wire fallback label; the score detail prints the value through its own bundle key label: `waist-to-height ${roundedRatio.toFixed(3)}`, asOf: row.at.toISOString(), sources: [row.source], diff --git a/src/lib/day/records.ts b/src/lib/day/records.ts index 2d8d6cd11..d4794e2a8 100644 --- a/src/lib/day/records.ts +++ b/src/lib/day/records.ts @@ -713,6 +713,7 @@ async function labsPart(frame: DayFrame): Promise { row.value !== null ? // Every recorded digit, without the float noise a converted // reading carries (the labs page prints the same figure). + // eslint-disable-next-line healthlog/no-unlocalised-decimal -- the day contract's meta is wire text; the lab figure keeps the notation it was stored in `${formatRecordedNumber(row.value, (v, digits) => v.toFixed(digits), row.sourceValue)} ${row.unit}`.trim() : (row.valueText ?? null), note: openText(row.noteEncrypted, "lab note"), diff --git a/src/lib/export/account-settings-backup.ts b/src/lib/export/account-settings-backup.ts index 2f7a426e5..2585a2c11 100644 --- a/src/lib/export/account-settings-backup.ts +++ b/src/lib/export/account-settings-backup.ts @@ -187,6 +187,7 @@ function isTimeZone(value: unknown): value is string { return false; } try { + // eslint-disable-next-line healthlog/no-unlocalised-decimal -- validates a zone name and formats nothing Intl.DateTimeFormat(undefined, { timeZone: value }); return true; } catch { diff --git a/src/lib/export/backup-blob.ts b/src/lib/export/backup-blob.ts index 0a06a33a5..c7e2fbd7e 100644 --- a/src/lib/export/backup-blob.ts +++ b/src/lib/export/backup-blob.ts @@ -55,6 +55,7 @@ export function packBackupBlob(json: string): string { function size(bytes: number): string { const mb = bytes / 1024 / 1024; if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + // eslint-disable-next-line healthlog/no-unlocalised-decimal -- operator log text about a backup size return mb < 100 ? `${mb.toFixed(1)} MB` : `${Math.round(mb)} MB`; } diff --git a/src/lib/format-locale.ts b/src/lib/format-locale.ts index 76434a6fa..415f3e95c 100644 --- a/src/lib/format-locale.ts +++ b/src/lib/format-locale.ts @@ -179,6 +179,23 @@ export interface Formatters { monthShort: (value: DateInput) => string; } +/** + * A decimal in the reader's notation for text built outside React (a server + * sentence, a deterministic fallback): at most `maxFractionDigits` digits, + * trailing zeros dropped, no grouping. `toFixed` prints a full stop in every + * language; this prints "0,8" in German and "0.8" in English. + */ +export function formatDecimal( + value: number, + locale: Locale, + maxFractionDigits: number, +): string { + return getNumberFormat(resolveIntlLocale(locale), { + maximumFractionDigits: maxFractionDigits, + useGrouping: false, + }).format(value); +} + /** * Build the locale-aware formatter set. * diff --git a/src/lib/import/unzip-export-xml.ts b/src/lib/import/unzip-export-xml.ts index 3b28942a1..c90673a18 100644 --- a/src/lib/import/unzip-export-xml.ts +++ b/src/lib/import/unzip-export-xml.ts @@ -278,6 +278,7 @@ export async function extractExportXml( MAX_COMPRESSION_RATIO ) { throw new Error( + // eslint-disable-next-line healthlog/no-unlocalised-decimal -- zero fraction digits: a whole ratio, no decimal mark to localise `export.xml advertises a ${( exportXmlEntry.uncompressedSize / exportXmlEntry.compressedSize ).toFixed(0)}× compression ratio (cap is ${MAX_COMPRESSION_RATIO}×)` + @@ -399,6 +400,7 @@ export async function extractHealthConnectDb( entry.uncompressedSize / entry.compressedSize > MAX_COMPRESSION_RATIO ) { throw new Error( + // eslint-disable-next-line healthlog/no-unlocalised-decimal -- zero fraction digits: a whole ratio, no decimal mark to localise `${HEALTH_CONNECT_DB_MEMBER} advertises a ${( entry.uncompressedSize / entry.compressedSize ).toFixed(0)}× compression ratio (cap is ${MAX_COMPRESSION_RATIO}×)` + diff --git a/src/lib/insights/__tests__/correlations.test.ts b/src/lib/insights/__tests__/correlations.test.ts index dee74ffc9..4caaab8fb 100644 --- a/src/lib/insights/__tests__/correlations.test.ts +++ b/src/lib/insights/__tests__/correlations.test.ts @@ -9,6 +9,7 @@ import { MAX_P_VALUE, } from "../correlations"; import { seededRng } from "./helpers/seeded-series"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; /** * v1.4.20 — correlation discovery. @@ -388,6 +389,26 @@ describe("correlateWeightWeekday", () => { } }); + it("prints the difference in the reader's notation", () => { + const daily: Array<{ weekday: number; weight: number }> = []; + for (let week = 0; week < 4; week++) { + daily.push({ weekday: 0, weight: 84.0 + week * 0.1 }); + for (let d = 1; d < 7; d++) daily.push({ weekday: d, weight: 80.0 }); + } + const de = correlateWeightWeekday( + { daily }, + getServerTranslator("de").t, + "de", + ); + const en = correlateWeightWeekday({ daily }); + if (de.status !== "ok" || en.status !== "ok") throw new Error("ok"); + // 84.15 against a grand mean of 80.59: a 3.6 difference. + expect(de.interpretation).toMatch(/3,6/); + expect(de.interpretation).not.toMatch(/3\.6/); + // Counter-check: English keeps the full stop. + expect(en.interpretation).toMatch(/3\.6/); + }); + it("returns insufficient when no weekday meaningfully differs", () => { const daily = Array.from({ length: 28 }, (_, i) => ({ weekday: i % 7, diff --git a/src/lib/insights/assessment-context.ts b/src/lib/insights/assessment-context.ts index 8cee9e9fb..cfc47795c 100644 --- a/src/lib/insights/assessment-context.ts +++ b/src/lib/insights/assessment-context.ts @@ -246,6 +246,7 @@ export function formatRelationsBlock( // Cap at two so the block never crowds out the metric's own grounding. const top = correlations.slice(0, 2); const lines = top.map( + // eslint-disable-next-line healthlog/no-unlocalised-decimal -- text for the model, never shown to a person (c) => `- ${c.interpretation} (n=${c.n}, r=${c.r.toFixed(2)})`, ); if (locale === "en") { diff --git a/src/lib/insights/correlations.ts b/src/lib/insights/correlations.ts index 8a11eb58f..86ba3e7a9 100644 --- a/src/lib/insights/correlations.ts +++ b/src/lib/insights/correlations.ts @@ -39,7 +39,8 @@ * the never-causal framing survives verbatim in all six locales rather than * leaking English into a non-English UI. */ -import { defaultLocale } from "@/lib/i18n/config"; +import { formatDecimal } from "@/lib/format-locale"; +import { defaultLocale, type Locale } from "@/lib/i18n/config"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { dayKeyToIndex, @@ -919,6 +920,7 @@ export interface WeightWeekdayInput { export function correlateWeightWeekday( input: WeightWeekdayInput, t: Translate = defaultTranslate(), + locale: Locale = defaultLocale, ): CorrelationResult { const points = input.daily.map((d) => ({ x: d.weekday, y: d.weight })); const anova = weekdayAnova( @@ -951,7 +953,8 @@ export function correlateWeightWeekday( const direction = t( `insights.correlation.weekdayDirection.${outlierMean > grandMean ? "above" : "below"}`, ); - const delta = Math.abs(outlierMean - grandMean).toFixed(1); + // In the reader's notation: the sentence is theirs ("0,8 kg" in German). + const delta = formatDecimal(Math.abs(outlierMean - grandMean), locale, 1); const interpretation = t("insights.correlation.hypothesis.weightWeekday", { weekday: dayLabel, delta, diff --git a/src/lib/insights/mood-narratives.ts b/src/lib/insights/mood-narratives.ts index a1fa24626..bf86593ae 100644 --- a/src/lib/insights/mood-narratives.ts +++ b/src/lib/insights/mood-narratives.ts @@ -158,6 +158,7 @@ function weekdayNarratives(input: MoodNarrativeInput): MoodNarrative[] { messageKey: "insights.mood.narrative.weekdayDip", vars: { weekdayKey: WEEKDAY_LABEL_KEYS[worst.weekday], + // eslint-disable-next-line healthlog/no-unlocalised-decimal -- wire value; the narrative feed parses it and prints it through useFormatters delta: dipDelta.toFixed(1), }, strength: dipDelta, @@ -171,6 +172,7 @@ function weekdayNarratives(input: MoodNarrativeInput): MoodNarrative[] { messageKey: "insights.mood.narrative.weekdayPeak", vars: { weekdayKey: WEEKDAY_LABEL_KEYS[best.weekday], + // eslint-disable-next-line healthlog/no-unlocalised-decimal -- wire value; the narrative feed parses it and prints it through useFormatters value: best.avgScore.toFixed(1), }, strength: peakDelta, @@ -208,6 +210,7 @@ function trendNarrative(input: MoodNarrativeInput): MoodNarrative | null { : "insights.mood.narrative.trendDown", vars: { direction: delta > 0 ? "up" : "down", + // eslint-disable-next-line healthlog/no-unlocalised-decimal -- wire value; the narrative feed parses it and prints it through useFormatters delta: Math.abs(delta).toFixed(1), days: String(w), }, @@ -248,6 +251,7 @@ function weekendNarrative(input: MoodNarrativeInput): MoodNarrative | null { : "insights.mood.narrative.weekendDown", vars: { direction: delta > 0 ? "up" : "down", + // eslint-disable-next-line healthlog/no-unlocalised-decimal -- wire value; the narrative feed parses it and prints it through useFormatters delta: Math.abs(delta).toFixed(1), }, strength: Math.abs(delta), @@ -321,6 +325,7 @@ function tagNarratives(input: MoodNarrativeInput): MoodNarrative[] { out.push({ kind: "tag-lift", messageKey: "insights.mood.narrative.tagLift", + // eslint-disable-next-line healthlog/no-unlocalised-decimal -- wire value; the narrative feed parses it and prints it through useFormatters vars: { ...tagVars(topLift), delta: topLift.delta.toFixed(1) }, strength: topLift.delta, }); @@ -332,6 +337,7 @@ function tagNarratives(input: MoodNarrativeInput): MoodNarrative[] { messageKey: "insights.mood.narrative.tagDrop", vars: { ...tagVars(topDrop), + // eslint-disable-next-line healthlog/no-unlocalised-decimal -- wire value; the narrative feed parses it and prints it through useFormatters delta: Math.abs(topDrop.delta).toFixed(1), }, strength: Math.abs(topDrop.delta), @@ -427,6 +433,7 @@ function timeOfDayNarrative(input: MoodNarrativeInput): MoodNarrative | null { messageKey: "insights.mood.narrative.timeOfDay", vars: { bucketKey: TIME_OF_DAY_LABEL_KEYS[best], + // eslint-disable-next-line healthlog/no-unlocalised-decimal -- wire value; the narrative feed parses it and prints it through useFormatters value: bestRow.avgScore.toFixed(1), }, strength: delta, diff --git a/src/lib/insights/narrative/period-narrative-deterministic.ts b/src/lib/insights/narrative/period-narrative-deterministic.ts index 9f85bb259..92d0c613f 100644 --- a/src/lib/insights/narrative/period-narrative-deterministic.ts +++ b/src/lib/insights/narrative/period-narrative-deterministic.ts @@ -17,6 +17,7 @@ * — with a prettified-enum fallback for any unmapped type, so a newly added * measurement type degrades gracefully instead of leaking an enum constant. */ +import { formatDecimal } from "@/lib/format-locale"; import type { PeriodNarrativeContext, MetricDelta } from "./period-narrative"; /** Marker stored in `InsightNarrative.providerType` for a fallback row. */ @@ -79,14 +80,9 @@ function labelFor(type: string, locale: Locale): string { /** Format a signed number with the locale's decimal mark (no thousands sep). */ function fmtSigned(n: number, locale: Locale): string { const sign = n > 0 ? "+" : n < 0 ? "−" : "±"; // U+2212 minus for typography - const abs = Math.abs(n); - // Integers print verbatim; fractions print at 2dp with trailing zeros - // trimmed (so 1.20 → 1.2, 1.50 → 1.5) WITHOUT touching integer zeros. - const s = Number.isInteger(abs) - ? String(abs) - : abs.toFixed(2).replace(/0+$/, "").replace(/\.$/, ""); - const localised = locale === "de" ? s.replace(".", ",") : s; - return `${sign}${localised}`; + // At most two decimals, trailing zeros trimmed (1.20 → 1.2), in the + // reader's notation in every language, not only German. + return `${sign}${formatDecimal(Math.abs(n), locale, 2)}`; } /** A delta is "notable" when it has a computable, non-zero change. */ diff --git a/src/lib/insights/no-key-fallbacks.ts b/src/lib/insights/no-key-fallbacks.ts index fc3fc42a1..259d71e25 100644 --- a/src/lib/insights/no-key-fallbacks.ts +++ b/src/lib/insights/no-key-fallbacks.ts @@ -1,4 +1,5 @@ import type { Locale } from "@/lib/i18n/config"; +import { formatDecimal } from "@/lib/format-locale"; import type { MetricSignal } from "@/lib/insights/metric-signal"; /** @@ -66,16 +67,18 @@ interface FallbackCopy { pointer: Localized; } -function fmtValue(value: number, copy: FallbackCopy): string { +function fmtValue( + value: number, + copy: FallbackCopy, + locale: InsightLocale, +): string { const digits = copy.digits ?? 0; - // Round to the metric's precision, but never render a trailing ".0" — an - // integer reading (steps, a whole-number BMI day) should read as "4200", - // not "4200.0", while a genuine fractional value keeps its decimal. + // Round to the metric's precision, but never render a trailing zero — an + // integer reading (steps, a whole-number BMI day) reads as "4200", not + // "4200.0" — and print the decimal in the reader's notation ("72,5"). const factor = 10 ** digits; const rounded = Math.round(value * factor) / factor; - const text = Number.isInteger(rounded) - ? String(rounded) - : rounded.toFixed(digits); + const text = formatDecimal(rounded, locale, digits); return copy.unit ? `${text}${copy.unit}` : text; } @@ -255,7 +258,7 @@ function composeGroundedFallback( if (!signal || !Number.isFinite(signal.current)) return null; const label = capitalise(pick(locale, copy.label)); - const current = fmtValue(signal.current, copy); + const current = fmtValue(signal.current, copy, locale); const sentences: string[] = []; @@ -271,7 +274,7 @@ function composeGroundedFallback( signal.delta !== 0 && Number.isFinite(signal.baseline) ) { - const baseline = fmtValue(signal.baseline, copy); + const baseline = fmtValue(signal.baseline, copy, locale); sentences.push( pick(locale, VALUE_VS_BASELINE)( label, diff --git a/src/lib/jobs/job-failures.ts b/src/lib/jobs/job-failures.ts index db7604b6d..3b4b8f004 100644 --- a/src/lib/jobs/job-failures.ts +++ b/src/lib/jobs/job-failures.ts @@ -129,7 +129,8 @@ function explainTimeout(message: string, expireSeconds: number | null): string { const limit = expireSeconds !== null && expireSeconds > 0 ? expireSeconds >= 3600 && expireSeconds % 3600 === 0 - ? `${expireSeconds / 3600} h` + ? // eslint-disable-next-line healthlog/no-unlocalised-decimal -- operator log text, a whole number of hours (checked above) + `${expireSeconds / 3600} h` : `${Math.round(expireSeconds / 60)} min` : null; return ( diff --git a/src/lib/jobs/offhost-backup.ts b/src/lib/jobs/offhost-backup.ts index 05a9f23b5..1016ce032 100644 --- a/src/lib/jobs/offhost-backup.ts +++ b/src/lib/jobs/offhost-backup.ts @@ -425,6 +425,7 @@ export function defaultOffhostObjectLimit(): number { function size(bytes: number): string { const mb = bytes / 1024 / 1024; if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + // eslint-disable-next-line healthlog/no-unlocalised-decimal -- operator log text about a backup size return mb < 100 ? `${mb.toFixed(1)} MB` : `${Math.round(mb)} MB`; } diff --git a/src/lib/labs/value-update.ts b/src/lib/labs/value-update.ts index 7dd945dc5..d0bdd4b81 100644 --- a/src/lib/labs/value-update.ts +++ b/src/lib/labs/value-update.ts @@ -17,6 +17,7 @@ export function valueEditSeed(value: number): string { MAX_SEED_DECIMALS, Math.max(DISPLAY_DECIMALS, SEED_SIGNIFICANT_FIGURES - 1 - magnitude), ); + // eslint-disable-next-line healthlog/no-unlocalised-decimal -- seeds an input whose parser reads the full stop back return value .toFixed(decimals) .replace(/(\.\d*?)0+$/, "$1") diff --git a/src/lib/record-settings/configuration.ts b/src/lib/record-settings/configuration.ts index 632460ef9..0ebc84a24 100644 --- a/src/lib/record-settings/configuration.ts +++ b/src/lib/record-settings/configuration.ts @@ -46,6 +46,7 @@ const timezoneSchema = z .max(64) .refine((timezone) => { try { + // eslint-disable-next-line healthlog/no-unlocalised-decimal -- validates a zone name and formats nothing Intl.DateTimeFormat(undefined, { timeZone: timezone }); return true; } catch { diff --git a/src/lib/workouts/route-svg.ts b/src/lib/workouts/route-svg.ts index 23d6d831c..2c4d29952 100644 --- a/src/lib/workouts/route-svg.ts +++ b/src/lib/workouts/route-svg.ts @@ -250,11 +250,13 @@ export function projectRoute( const vbPoints = simplified.map(toVB); const path = vbPoints + // eslint-disable-next-line healthlog/no-unlocalised-decimal -- SVG path data and viewBox, read by the renderer .map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(2)} ${p.y.toFixed(2)}`) .join(" "); return { path, + // eslint-disable-next-line healthlog/no-unlocalised-decimal -- SVG path data and viewBox, read by the renderer viewBox: `0 0 ${VIEW_W} ${height.toFixed(2)}`, width: VIEW_W, height,