mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
Merge the final display polish into release/v1.42.0
This commit is contained in:
commit
dcfec1f35d
144 files changed
+4866
-2817
No files matched your search
@@ -81,6 +81,8 @@ const DENSE_TYPES: ReadonlySet<string> = new Set([
|
||||
* list, keyed `<file>::<type expression>`, with why each stays bounded.
|
||||
*/
|
||||
const ALLOWED: Record<string, string> = {
|
||||
"lib/analytics/summaries-slice.ts::(shorthand)":
|
||||
"one hourly-mean type (pulse) over one local day, the latest: the same rows the day view reads for that day, bounded by a day, not by the sampling history",
|
||||
"lib/insights/comprehensive-generate.ts::(shorthand)":
|
||||
'Only reached for SLEEP_DURATION (guarded by `type === "SLEEP_DURATION"`); a few stage rows per night.',
|
||||
"lib/insights/derived/vascular-age.ts::VASCULAR_AGE_TYPE":
|
||||
|
||||
@@ -3,14 +3,18 @@ import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* No middle-dot separator in visible strings.
|
||||
* No middle-dot or dash 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.
|
||||
* as a list of fragments rather than as the app's plain voice, and so does
|
||||
* "{what} — tomorrow" or "Could not save — try again": the dash stands in
|
||||
* for a comma, a colon or a full stop the sentence should have said. 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, then U+00B7 (middle dot), U+2014 (em dash) or U+2013 (en dash),
|
||||
* then a space. A middle dot inside a unit ("mL/(kg·min)") and a range
|
||||
* ("70–100 mmHg", "{start}–{end}") have no surrounding spaces and are not
|
||||
* matched: an unspaced en dash is how a range is written, not a pause.
|
||||
*
|
||||
* 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
|
||||
@@ -18,7 +22,10 @@ import { join } from "node:path";
|
||||
*/
|
||||
|
||||
const MESSAGES_DIR = join(__dirname, "../../messages");
|
||||
const SEPARATOR = " · ";
|
||||
const SEPARATORS = [" · ", " — ", " – "] as const;
|
||||
|
||||
const hasSeparator = (value: string) =>
|
||||
SEPARATORS.some((separator) => value.includes(separator));
|
||||
|
||||
/** Key path -> why the separator is right there. Empty by intent. */
|
||||
const ALLOWED: Record<string, string> = {};
|
||||
@@ -41,7 +48,7 @@ const bundles = readdirSync(MESSAGES_DIR)
|
||||
.filter((file) => file.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
describe("message bundles carry no ' · ' separator", () => {
|
||||
describe("message bundles carry no ' · ', ' — ' or ' – ' separator", () => {
|
||||
it("finds the bundles", () => {
|
||||
expect(bundles.length).toBeGreaterThanOrEqual(7);
|
||||
});
|
||||
@@ -57,18 +64,16 @@ describe("message bundles carry no ' · ' separator", () => {
|
||||
expect(values.size).toBeGreaterThan(1000);
|
||||
});
|
||||
|
||||
it(`${file} has no dot separator outside the allowlist`, () => {
|
||||
it(`${file} has no dot or dash separator outside the allowlist`, () => {
|
||||
const offenders = [...values]
|
||||
.filter(
|
||||
([key, value]) => value.includes(SEPARATOR) && !(key in ALLOWED),
|
||||
)
|
||||
.filter(([key, value]) => hasSeparator(value) && !(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),
|
||||
(key) => !hasSeparator(values.get(key) ?? ""),
|
||||
);
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
|
||||
/**
|
||||
* The code-side twin of `i18n-no-dot-separator.test.ts`: a string the code
|
||||
* composes itself (an aria-label `${action} — ${name}`, a page title, a
|
||||
* Coach prefill) carries no spaced em or en dash either. The bundles say
|
||||
* "Move up: Ramipril" and "No connection: changes can't be saved", so a
|
||||
* label put together in a component says it the same way.
|
||||
*
|
||||
* The matcher looks at string and template literals on one line, outside
|
||||
* comments: a quote or backtick, then text, a space, U+2014 or U+2013, a
|
||||
* space. A range written in code ("10–12", `${from}–${to}`) has no spaces
|
||||
* around its dash and is not matched. A template literal broken across lines
|
||||
* escapes the one-line matcher, which is this guard's known limit.
|
||||
*/
|
||||
|
||||
const ROOT = join(__dirname, "..");
|
||||
const DIRS = ["components", "app"].map((d) => join(ROOT, d));
|
||||
const LITERAL = /(["'`])[^"'`]* [—–] [^"'`]*\1/;
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const path = join(dir, name);
|
||||
if (statSync(path).isDirectory()) {
|
||||
if (name === "__tests__") continue;
|
||||
walk(path, out);
|
||||
} else if (/\.(ts|tsx)$/.test(name) && !/\.test\./.test(name)) {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const isComment = (line: string) => /^\s*(\/\/|\*|\/\*|\{\/\*)/.test(line);
|
||||
|
||||
describe("composed UI strings carry no spaced dash", () => {
|
||||
const files = DIRS.flatMap((d) => walk(d));
|
||||
|
||||
it("walks the component and app trees", () => {
|
||||
expect(files.length).toBeGreaterThan(500);
|
||||
});
|
||||
|
||||
it("has no ' — ' or ' – ' inside a string or template literal", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const file of files) {
|
||||
readFileSync(file, "utf8")
|
||||
.split("\n")
|
||||
.forEach((line, i) => {
|
||||
if (isComment(line)) return;
|
||||
const code = line.replace(/\s\/\/\s.*$/, "");
|
||||
if (LITERAL.test(code)) {
|
||||
offenders.push(`${relative(ROOT, file)}:${i + 1}: ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("catches the pattern it is written for", () => {
|
||||
expect(LITERAL.test("aria-label={`${a} — ${b}`}")).toBe(true);
|
||||
expect(LITERAL.test('title: "About – HealthLog"')).toBe(true);
|
||||
expect(LITERAL.test("`${from}–${to}`")).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user