fix(i18n): drop dot and dash separators from visible strings

Every bundle value that chained facts with " · " now reads as a phrase,
a comma list or a parenthesis, in all seven locales, and the em-dash
separators in the day, documents, correlation, sharing, environment,
vaccination and Apple Health import strings become sentences or clauses.

The day view's running list names the record first, with the dose in
the name and a life event's category as a chip, and puts a quiet day
count below: "Day 4, since 31 December" instead of a dot chain. Document
meta, location periods, vaccination rows and mood crosstab rows drop
their composed dots too.

A guard walks every bundle and fails on any " · " outside a reasoned
allowlist, and on an allowlist entry that no longer needs its place.
This commit is contained in:
Marc-André Bombeck committed 2026-10-09 13:37:14 +02:00
1 parent d8a3614120
commit 94542e7f2d
39 files changed
+1027 -881

No files matched your search

@@ -0,0 +1,76 @@
import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
/**
* No middle-dot separator in visible strings.
*
* A value like "Added {date} · {size}" or "Weight · body fat · muscle" reads
* as a list of fragments rather than as the app's plain voice. Facts are
* joined with a comma, a colon or a real phrase instead. This guard walks
* every bundle under `messages/` and fails on any string that contains a
* space, U+00B7 and a space. A middle dot inside a unit ("mL/(kg·min)") has
* no surrounding spaces and is not matched.
*
* A key that genuinely needs the pattern goes into `ALLOWED` with the reason
* written next to it. An allowed key that no longer contains the pattern
* fails too, so the list cannot go stale.
*/
const MESSAGES_DIR = join(__dirname, "../../messages");
const SEPARATOR = " · ";
/** Key path -> why the separator is right there. Empty by intent. */
const ALLOWED: Record<string, string> = {};
function flatten(
obj: unknown,
prefix: string,
out: Map<string, string>,
): Map<string, string> {
if (obj == null || typeof obj !== "object") return out;
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
const key = prefix ? `${prefix}.${k}` : k;
if (typeof v === "string") out.set(key, v);
else if (typeof v === "object") flatten(v, key, out);
}
return out;
}
const bundles = readdirSync(MESSAGES_DIR)
.filter((file) => file.endsWith(".json"))
.sort();
describe("message bundles carry no ' · ' separator", () => {
it("finds the bundles", () => {
expect(bundles.length).toBeGreaterThanOrEqual(7);
});
for (const file of bundles) {
const values = flatten(
JSON.parse(readFileSync(join(MESSAGES_DIR, file), "utf8")),
"",
new Map(),
);
it(`${file} walks a non-empty set of strings`, () => {
expect(values.size).toBeGreaterThan(1000);
});
it(`${file} has no dot separator outside the allowlist`, () => {
const offenders = [...values]
.filter(
([key, value]) => value.includes(SEPARATOR) && !(key in ALLOWED),
)
.map(([key, value]) => `${key} = ${JSON.stringify(value)}`);
expect(offenders).toEqual([]);
});
it(`${file} has no stale allowlist entry`, () => {
const stale = Object.keys(ALLOWED).filter(
(key) => !(values.get(key) ?? "").includes(SEPARATOR),
);
expect(stale).toEqual([]);
});
}
});