mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
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
@@ -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>`;
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
@@ -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)`
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user