From 14a2931a991cd40ccf6aa6f41b6e0a442c1ab61d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 21:54:01 +0200 Subject: [PATCH] fix(daily): read signal deltas in the reader's number format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit formatSignalDelta matched only the locale's decimal mark, so an English-formatted figure inside German text was cut at the comma ("+1,234.5 kg" read "+1,2.5 kg"), and a difference that rounded to nothing lost its sign ("+0.04 kg" read "0.0 kg"). A figure with a decimal or grouping mark is now read as a whole: with a point and a comma the later one is the decimal, thin spaces and repeated marks are grouping, and a single mark is grouping only when it is the locale's grouping mark before exactly three digits. The figure is re-rounded to the metric's precision and re-written with the locale's own marks. A non-zero difference that rounds to nothing reads "±0" at that precision. Plain integers stay as written. Glucose joins the reading types, its precision following the unit the delta is written in (whole mg/dL, one decimal of mmol/L). The map is keyed by string, because the published sourceMetric enum has no glucose value yet and widening it would change the iOS contract. Tests cover all seven locales. --- .../daily/__tests__/briefing-today.test.ts | 66 ++++++++- src/lib/daily/briefing-today.ts | 133 +++++++++++++----- 2 files changed, 164 insertions(+), 35 deletions(-) diff --git a/src/lib/daily/__tests__/briefing-today.test.ts b/src/lib/daily/__tests__/briefing-today.test.ts index 7a9ff8f00..0be476d9f 100644 --- a/src/lib/daily/__tests__/briefing-today.test.ts +++ b/src/lib/daily/__tests__/briefing-today.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import type { DailyBriefing } from "@/lib/ai/schema"; +import { locales } from "@/lib/i18n/config"; +import { resolveIntlLocale } from "@/lib/format-locale"; import { briefingForToday, formatSignalDelta, @@ -37,9 +39,28 @@ describe("formatSignalDelta", () => { expect(formatSignalDelta("1.234 bpm", "pulse", "de")).toBe("1.234 bpm"); }); - it("drops the sign of a difference that rounds to nothing", () => { + it("says ±0 for a difference that rounds to nothing, never an unsigned zero", () => { expect(formatSignalDelta("+0.3 bpm vs your normal", "pulse", "en")).toBe( - "0 bpm vs your normal", + "±0 bpm vs your normal", + ); + expect(formatSignalDelta("+0.04 kg", "weight", "en")).toBe("±0.0 kg"); + }); + + it("re-writes a figure in the reader's own number format", () => { + // An English-formatted figure inside German text was cut at the comma + // into "+1,2.5 kg". + expect(formatSignalDelta("+1,234.5 kg", "weight", "de")).toBe( + "+1.234,5 kg", + ); + expect(formatSignalDelta("+1.234,56 kg", "weight", "de")).toBe( + "+1.234,6 kg", + ); + }); + + it("reads glucose at the precision of the unit it is written in", () => { + expect(formatSignalDelta("+12.4 mg/dL", "glucose", "en")).toBe("+12 mg/dL"); + expect(formatSignalDelta("+0,74 mmol/L", "glucose", "de")).toBe( + "+0,7 mmol/L", ); }); @@ -48,6 +69,47 @@ describe("formatSignalDelta", () => { }); }); +describe.each(locales)("formatSignalDelta in %s", (locale) => { + const intl = resolveIntlLocale(locale); + // The locale's own grouping convention: Spanish and Polish leave a + // four-digit figure ungrouped, French groups with a thin space. + const fmt = (value: number, digits: number) => + new Intl.NumberFormat(intl, { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }).format(value); + + it("rounds a pulse difference to whole beats", () => { + expect(formatSignalDelta(`+${fmt(33.72, 2)} bpm`, "pulse", locale)).toBe( + "+34 bpm", + ); + }); + + it("reads weight at one decimal, grouping included", () => { + expect(formatSignalDelta(`−${fmt(1234.56, 2)} kg`, "weight", locale)).toBe( + `−${fmt(1234.6, 1)} kg`, + ); + }); + + it("keeps the sign of a small difference as ±0 at weight precision", () => { + expect(formatSignalDelta(`+${fmt(0.04, 2)} kg`, "weight", locale)).toBe( + `±${fmt(0, 1)} kg`, + ); + }); + + it("re-reads an English-formatted figure in the reader's format", () => { + expect(formatSignalDelta("+1,234.5 kg", "weight", locale)).toBe( + `+${fmt(1234.5, 1)} kg`, + ); + }); + + it("leaves plain integers and window words as written", () => { + expect(formatSignalDelta("+6 mmHg vs 30-day", "bp", locale)).toBe( + "+6 mmHg vs 30-day", + ); + }); +}); + const BRIEFING: DailyBriefing = { paragraph: "Dein Puls ist heute deutlich erhöht.", signalsOfDay: [ diff --git a/src/lib/daily/briefing-today.ts b/src/lib/daily/briefing-today.ts index bce8c7be8..e4c2280f2 100644 --- a/src/lib/daily/briefing-today.ts +++ b/src/lib/daily/briefing-today.ts @@ -19,7 +19,7 @@ * metric's display precision, so a pulse difference reads "+34 bpm", never * "+33.72 bpm". */ -import type { DailyBriefing, DailyBriefingSignal } from "@/lib/ai/schema"; +import type { DailyBriefing } from "@/lib/ai/schema"; import type { Locale } from "@/lib/i18n/config"; import { resolveIntlLocale } from "@/lib/format-locale"; import { vitalDisplayDecimals } from "@/lib/measurements/vital-precision"; @@ -30,66 +30,133 @@ import { userDayKey } from "@/lib/tz/format"; * here (sleep, steps, mood, compliance, derived scores) are not a single * dated reading and keep their signal; the prompt input already decides * those. + * + * Keyed by string rather than by the briefing's `sourceMetric` enum: the + * signals-of-the-day block does carry glucose, and the published enum + * (which the iOS client decodes) does not yet. A glucose signal is covered + * here the day the enum gains it, without a contract change today. */ -export const SIGNAL_READING_TYPE: Partial< - Record -> = { +export const SIGNAL_READING_TYPE: Readonly> = { bp: "BLOOD_PRESSURE_SYS", weight: "WEIGHT", pulse: "PULSE", resting_hr: "RESTING_HEART_RATE", hrv: "HEART_RATE_VARIABILITY", body_temp: "BODY_TEMPERATURE", + glucose: "BLOOD_GLUCOSE", }; -/** The locale's decimal separator ("," for de, "." for en). */ -function decimalSeparator(locale: Locale): string { - return ( - new Intl.NumberFormat(resolveIntlLocale(locale)) - .formatToParts(1.5) - .find((part) => part.type === "decimal")?.value ?? "." +/** The locale's decimal and grouping separators ("," and "." for de). */ +function separators(locale: Locale): { decimal: string; group: string } { + const parts = new Intl.NumberFormat(resolveIntlLocale(locale)).formatToParts( + 12345.5, ); + return { + decimal: parts.find((p) => p.type === "decimal")?.value ?? ".", + group: parts.find((p) => p.type === "group")?.value ?? ",", + }; } -function escapeRegExp(text: string): string { - return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +/** Separators a figure may carry: point, comma, and the two thin spaces. */ +const SEP = "[.,\\u00a0\\u202f]"; + +/** + * A figure with at least one separator in it. Plain integers ("+6 mmHg", + * "30-day") are never rewritten, so the window words stay as written. + */ +const FIGURE = new RegExp( + `(? m[0]); + const points = marks.filter((m) => m === "." || m === ","); + const spaced = points.length < marks.length; + let decimal: string | null = null; + if (new Set(points).size === 2) { + decimal = raw.lastIndexOf(".") > raw.lastIndexOf(",") ? "." : ","; + } else if (points.length === 1) { + // Beside thin-space grouping ("1 234,5") the one mark is the decimal. + const mark = points[0]; + const after = raw.slice(raw.indexOf(mark) + 1); + const asGroup = !spaced && mark === localeGroup && after.length === 3; + decimal = asGroup ? null : mark; + } + // Repeated marks of one kind, and thin spaces, are grouping. + const [intPart, fracPart = ""] = + decimal === null + ? [raw] + : [ + raw.slice(0, raw.lastIndexOf(decimal)), + raw.slice(raw.lastIndexOf(decimal) + 1), + ]; + const groups = intPart.split(/[.,\u00a0\u202f]/); + if (groups.length > 1 && groups.slice(1).some((g) => g.length !== 3)) { + return null; + } + const value = Number(`${groups.join("")}.${fracPart || "0"}`); + if (!Number.isFinite(value)) return null; + // Written the way the locale writes it: decimal mark and grouping both. + const native = + (decimal === null || decimal === localeDecimal) && + marks.every((m) => m === decimal || m === localeGroup); + return { value, fractionDigits: fracPart.length, native }; } /** * Re-read the figures in a signal's delta string at the metric's display - * precision. + * precision, in the reader's number format. * - * Only a number written with the locale's own decimal separator is touched, - * and only when it carries more fraction digits than the metric is shown - * with: "1.234" in German is a thousand, not a decimal, and is left alone. - * A figure that rounds to zero loses its sign ("+0.3 bpm" reads "0 bpm"). - * A metric with no reading type keeps its delta verbatim. + * Only figures with a decimal or grouping mark are touched; a plain integer + * stays as written. A figure already at the metric's precision and written + * the locale's way is left alone; anything else is re-rounded and re-written + * with the locale's own marks ("+1,234.5 kg" in German reads "+1.234,5 kg"). + * A non-zero difference that rounds to nothing reads "±0" at the metric's + * precision rather than an unsigned zero that looks like a measurement. A + * metric with no reading type keeps its delta verbatim. */ export function formatSignalDelta( delta: string, - sourceMetric: DailyBriefingSignal["sourceMetric"], + sourceMetric: string, locale: Locale, ): string { const type = SIGNAL_READING_TYPE[sourceMetric]; if (!type) return delta; - // Every reading type above reads at the same precision in both unit - // systems (whole bpm / mmHg / ms, one decimal of kg / lb / °C / °F). - const decimals = vitalDisplayDecimals(type, 1); - const sep = decimalSeparator(locale); + // Every reading type reads at one precision in both unit systems (whole + // bpm / mmHg / ms, one decimal of kg / lb / °C / °F), except glucose, + // whose precision follows the unit the delta is written in. + const decimals = vitalDisplayDecimals( + type, + type === "BLOOD_GLUCOSE" && /mmol/i.test(delta) ? 1 : 0, + ); const number = new Intl.NumberFormat(resolveIntlLocale(locale), { minimumFractionDigits: decimals, maximumFractionDigits: decimals, - useGrouping: false, }); - const pattern = new RegExp( - `([+\\-−±]?)(\\d+)${escapeRegExp(sep)}(\\d+)(?![\\d])`, - "g", - ); - return delta.replace(pattern, (match, sign: string, int: string, frac) => { - if ((frac as string).length <= decimals) return match; - const value = Number(`${int}.${frac as string}`); - const rounded = Math.round(value * 10 ** decimals) / 10 ** decimals; - return `${rounded === 0 ? "" : sign}${number.format(rounded)}`; + return delta.replace(FIGURE, (match, sign: string, raw: string) => { + const figure = readFigure(raw, locale); + if (!figure) return match; + if (figure.native && figure.fractionDigits <= decimals) return match; + const scale = 10 ** decimals; + const rounded = Math.round(figure.value * scale) / scale; + if (rounded === 0 && figure.value !== 0) return `±${number.format(0)}`; + return `${sign}${number.format(rounded)}`; }); }