fix(daily): read signal deltas in the reader's number format

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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-05 21:54:01 +02:00
1 parent 28517affd0
commit 14a2931a99
2 files changed
+164 -35

No files matched your search

+64 -2
View File
@@ -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: [
+100 -33
View File
@@ -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<DailyBriefingSignal["sourceMetric"], string>
> = {
export const SIGNAL_READING_TYPE: Readonly<Record<string, string>> = {
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(
`(?<![\\p{L}\\d])([+\\-−±]?)(\\d+(?:${SEP}\\d+)+)(?![\\d])`,
"gu",
);
/**
* Read one written figure: which separator is the decimal one, and whether
* the rest are thousands groups. Null when the grouping is not well formed,
* in which case the figure is left as written.
*
* Both a point and a comma: the later one is the decimal. One kind only:
* repeated, or a thin space, it is grouping; once, it is grouping only when
* it is the locale's grouping mark followed by exactly three digits ("1.234"
* in German, "1,234" in English), otherwise a decimal, whichever convention
* the model wrote in ("33.72" inside German text is still 33.72).
*/
function readFigure(
raw: string,
locale: Locale,
): { value: number; fractionDigits: number; native: boolean } | null {
const { decimal: localeDecimal, group: localeGroup } = separators(locale);
const marks = [...raw.matchAll(/[.,\u00a0\u202f]/g)].map((m) => 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)}`;
});
}