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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 16:52:08 +02:00
1 parent c8649faddf
commit 6182d45354
24 files changed
+126 -29

No files matched your search

@@ -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,
@@ -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.
*
* - `<x>.toFixed(…)` and `<x>.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;
+2 -1
View File
@@ -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 ... -- <reason>`;
+7 -3
View File
@@ -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}`;
};
+2
View File
@@ -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)`
+2
View File
@@ -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.`,
});
}
@@ -359,6 +359,7 @@ export async function computeCorrelationHypothesesFastPath(
const weightWeekday = correlateWeightWeekday(
{ daily: weightWeekdayPairs },
t,
input.locale ?? defaultLocale,
);
annotate({
+1
View File
@@ -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],
+1
View File
@@ -713,6 +713,7 @@ async function labsPart(frame: DayFrame): Promise<SectionPart> {
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"),
@@ -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 {
+1
View File
@@ -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`;
}
+17
View File
@@ -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.
*
+2
View File
@@ -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}×)` +
@@ -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,
+1
View File
@@ -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") {
+5 -2
View File
@@ -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,
+7
View File
@@ -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,
@@ -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. */
+12 -9
View File
@@ -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<string>;
}
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,
+2 -1
View File
@@ -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 (
+1
View File
@@ -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`;
}
+1
View File
@@ -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")
+1
View File
@@ -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 {
+2
View File
@@ -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,