fix(i18n): drop spaced dashes as separators from every visible string

A dash between two parts of a sentence ("{what} — tomorrow", "Could not
record {name} — try again", "End {name} — …") stands in for a comma, a
colon or a full stop the sentence should say. Every such string in the
seven bundles now says it in plain words. A range keeps its unspaced en
dash ("70–100 mmHg", "{start}–{end}"); that is how a range is written,
not a pause, and the spaced range forms became unspaced ones.

Labels the components put together themselves follow the same rule:
aria-labels read "Move up: Ramipril", a medication and its dose read
"Ramipril 5 mg", page titles use "|", Coach prefills are two sentences,
and the API's user-facing error messages lost their dashes too.

The bundle guard now refuses " — " and " – " beside " · ", with an empty
allowlist, and a sibling guard walks src/components and src/app for a
string or template literal that joins its parts with a spaced dash.
This commit is contained in:
Marc-André Bombeck committed 2026-10-09 16:05:32 +02:00
1 parent 1d080ee166
commit 86a3856ae9
107 files changed
+2485 -2412

No files matched your search

+406 -406
View File
File diff suppressed because it is too large. Load diff
+387 -387
View File
File diff suppressed because it is too large. Load diff
+160 -160
View File
File diff suppressed because it is too large. Load diff
+309 -309
View File
File diff suppressed because it is too large. Load diff
+249 -249
View File
File diff suppressed because it is too large. Load diff
+341 -341
View File
File diff suppressed because it is too large. Load diff
+383 -383
View File
File diff suppressed because it is too large. Load diff
+18 -11
View File
@@ -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,10 +64,10 @@ 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),
([key, value]) => hasSeparator(value) && !(key in ALLOWED),
)
.map(([key, value]) => `${key} = ${JSON.stringify(value)}`);
expect(offenders).toEqual([]);
@@ -68,7 +75,7 @@ describe("message bundles carry no ' · ' separator", () => {
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);
});
});
+1 -1
View File
@@ -44,7 +44,7 @@ export const dynamic = "force-dynamic";
const LAST_UPDATED = "2026-08-02";
export const metadata: Metadata = {
title: "About — HealthLog",
title: "About | HealthLog",
description:
"Open-source credits and third-party data attributions for the HealthLog project.",
robots: { index: true, follow: true },
@@ -84,7 +84,7 @@ const handler = apiHandler(
details: { reason: "missing_confirmation", backupId: id },
});
return apiError(
"Confirmation token missing — replacing an account's data requires confirm: 'RESTORE'",
"Confirmation token missing. Replacing an account's data requires confirm: 'RESTORE'",
422,
);
}
@@ -45,7 +45,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
userCode = decoded.userCode;
} catch {
cookieStore.delete("central_codex_device");
return apiError("Invalid device-auth state — restart the flow", 400);
return apiError("Invalid device-auth state. Restart the flow", 400);
}
let result;
@@ -55,7 +55,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const message = err instanceof Error ? err.message : "unknown";
annotate({ meta: { central_codex_device_poll_error: message } });
return apiError(
"Central Codex device-poll failed — please restart the flow",
"Central Codex device-poll failed. Please restart the flow",
502,
);
}
+3 -3
View File
@@ -276,13 +276,13 @@ function classifyTestFailure(
return {
reasonCode: "credentials",
reason:
"Provider rejected the credentials — re-authenticate in AI settings.",
"Provider rejected the credentials. Re-authenticate in AI settings.",
};
}
if (status === 429) {
return {
reasonCode: "rate_limited",
reason: "Provider rate-limited the request — try again shortly.",
reason: "Provider rate-limited the request. Try again shortly.",
};
}
if (status >= 500) {
@@ -298,7 +298,7 @@ function classifyTestFailure(
if (status >= 400 && status < 500) {
return {
reasonCode: "bad_request",
reason: `The provider rejected the request (HTTP ${status}) — the endpoint answered, so this is a request-shape or model-name problem, not connectivity.`,
reason: `The provider rejected the request (HTTP ${status}). The endpoint answered, so this is a request-shape or model-name problem, not connectivity.`,
};
}
return {
+2 -2
View File
@@ -47,7 +47,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
userCode = decoded.userCode;
} catch {
cookieStore.delete("codex_device");
return apiError("Invalid device-auth state — restart the flow", 400);
return apiError("Invalid device-auth state. Restart the flow", 400);
}
let result;
@@ -56,7 +56,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
} catch (err) {
const message = err instanceof Error ? err.message : "unknown";
annotate({ meta: { codex_device_poll_error: message } });
return apiError("Codex device-poll failed — please restart the flow", 502);
return apiError("Codex device-poll failed. Please restart the flow", 502);
}
if (result.status === "pending") {
@@ -88,7 +88,7 @@ const healthScoreConfigPatchSchema = z
*/
const BREADTH_REFUSAL = {
no_pillars_selected:
"A health score needs at least one area of health to score. Keep one selected — the score will say which areas it is built from.",
"A health score needs at least one area of health to score. Keep one selected. The score will say which areas it is built from.",
} as const;
export const GET = apiHandler(async () => {
+1 -1
View File
@@ -135,7 +135,7 @@ export const DELETE = apiHandler(
if (passkeyCount <= 1 && !hasPassword) {
return apiError(
"Cannot delete — at least one authentication method must remain",
"Cannot delete. At least one authentication method must remain",
400,
);
}
+2 -2
View File
@@ -96,14 +96,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
// the token and re-authenticates.
if (result.reason === "already_used") {
return apiError(
"Refresh token reuse detected — please log in again.",
"Refresh token reuse detected. Please log in again.",
401,
{ errorCode: "auth.refresh.reuse" },
);
}
if (result.reason === "revoked") {
return apiError(
"Refresh token has been revoked — please log in again.",
"Refresh token has been revoked. Please log in again.",
401,
{ errorCode: "auth.refresh.revoked" },
);
+1 -1
View File
@@ -58,7 +58,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (existingSession) {
annotate({ action: { name: "auth.register.refused_authenticated" } });
return apiError(
"Already signed in — sign out first to use an invitation",
"Already signed in. Sign out first to use an invitation",
409,
{ errorCode: "auth.already_authenticated" },
);
+1 -1
View File
@@ -297,7 +297,7 @@ export const PATCH = apiHandler(
if (outcome === "contradictory-reminder") {
return apiError(
"A booked visit cannot also close a checkup — it has not happened yet",
"A booked visit cannot also close a checkup. It has not happened yet",
422,
{ errorCode: "encounter.reminder-conflict" },
);
+1 -1
View File
@@ -301,7 +301,7 @@ async function postEncounter(request: NextRequest): Promise<Response> {
}
if (created === "contradictory-reminder") {
return apiError(
"A planned visit cannot also close a checkup — it has not happened yet",
"A planned visit cannot also close a checkup. It has not happened yet",
422,
{ errorCode: "encounter.reminder-conflict" },
);
+1 -1
View File
@@ -149,7 +149,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
if (okRows.length > MAX_ROWS) {
return apiError(
`CSV has ${okRows.length} valid rows — the limit is ${MAX_ROWS} per import`,
`CSV has ${okRows.length} valid rows. The limit is ${MAX_ROWS} per import`,
422,
);
}
@@ -1240,7 +1240,7 @@ exports[`coach chat golden transcripts > 04 inbound refusal on a new conversatio
},
{
"call": "appendMessage",
"content": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data — please rephrase your question in those terms.",
"content": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data. Please rephrase your question in those terms.",
"metricSource": {
"metrics": [
"general",
@@ -1256,7 +1256,7 @@ exports[`coach chat golden transcripts > 04 inbound refusal on a new conversatio
"contentType": "text/event-stream; charset=utf-8",
"frames": [
{
"token": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data — please rephrase your question in those terms.",
"token": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data. Please rephrase your question in those terms.",
"type": "token",
},
{
@@ -1302,7 +1302,7 @@ exports[`coach chat golden transcripts > 05 replay injection in a stored user tu
},
{
"call": "appendMessage",
"content": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data — please rephrase your question in those terms.",
"content": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data. Please rephrase your question in those terms.",
"metricSource": {
"metrics": [
"general",
@@ -1318,7 +1318,7 @@ exports[`coach chat golden transcripts > 05 replay injection in a stored user tu
"contentType": "text/event-stream; charset=utf-8",
"frames": [
{
"token": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data — please rephrase your question in those terms.",
"token": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data. Please rephrase your question in those terms.",
"type": "token",
},
{
+6 -6
View File
@@ -810,7 +810,7 @@ export const POST = apiHandler((request: NextRequest) =>
// bounds cost. 429 with a distinct code so the client can say so.
if (e instanceof BriefingBudgetExceededError) {
return apiError(
"Daily AI token budget reached — insights will be available again tomorrow.",
"Daily AI token budget reached. Insights will be available again tomorrow.",
429,
{ errorCode: "insights.generate.budgetExceeded" },
);
@@ -859,7 +859,7 @@ export const POST = apiHandler((request: NextRequest) =>
);
if (allAuth) {
return apiError(
"AI provider rejected the request — check your API key in Settings > AI",
"AI provider rejected the request. Check your API key in Settings > AI",
422,
);
}
@@ -881,7 +881,7 @@ export const POST = apiHandler((request: NextRequest) =>
);
if (allTransport) {
return apiError(
"AI provider connection failed — check your AI settings",
"AI provider connection failed. Check your AI settings",
422,
);
}
@@ -907,7 +907,7 @@ export const POST = apiHandler((request: NextRequest) =>
const status = err.httpStatus ?? 0;
if (status === 401 || status === 403) {
return apiError(
"AI provider rejected the request — check your API key in Settings > AI",
"AI provider rejected the request. Check your API key in Settings > AI",
422,
);
}
@@ -921,7 +921,7 @@ export const POST = apiHandler((request: NextRequest) =>
);
}
return apiError(
"AI provider connection failed — check your AI settings",
"AI provider connection failed. Check your AI settings",
422,
);
}
@@ -945,7 +945,7 @@ export const POST = apiHandler((request: NextRequest) =>
if (result.finishReason === "length") {
annotate({ meta: { insights_response_truncated: true } });
return apiError(
"AI response was cut off before the JSON completed — raise the token limit (INSIGHTS_MAX_TOKENS)",
"AI response was cut off before the JSON completed. Raise the token limit (INSIGHTS_MAX_TOKENS)",
422,
{ errorCode: "ai_response_truncated" },
);
+2 -2
View File
@@ -172,14 +172,14 @@ export const PUT = apiHandler(
const rowIsQualitative = existing.valueText !== null;
if (rowIsQualitative && d.value !== undefined) {
return apiError(
"This is a qualitative reading — edit its result text, not a numeric value",
"This is a qualitative reading. Edit its result text, not a numeric value",
422,
{ errorCode: "labs.update.qualitativeExpected" },
);
}
if (!rowIsQualitative && d.valueText !== undefined) {
return apiError(
"This is a numeric reading — edit its value, not a qualitative result",
"This is a numeric reading. Edit its value, not a qualitative result",
422,
{ errorCode: "labs.update.numericExpected" },
);
+3 -3
View File
@@ -85,7 +85,7 @@ function badRequest(error: string, description: string): Response {
function html(body: string, status = 200): Response {
return new Response(
`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>HealthLog — Authorize access</title></head><body>${body}</body></html>`,
`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>HealthLog: Authorize access</title></head><body>${body}</body></html>`,
{
status,
headers: { "content-type": "text/html; charset=utf-8" },
@@ -419,13 +419,13 @@ async function authorizeGet(request: NextRequest): Promise<Response> {
const provenance =
v.clientSource === "cimd"
? `<p>Verified origin: <code>${htmlEscape(v.clientOrigin)}</code></p>`
: `<p><strong>Unverified application</strong> (dynamically registered — the name above is self-reported and not verified).</p>`;
: `<p><strong>Unverified application</strong> (dynamically registered, the name above is self-reported and not verified).</p>`;
// Plain-language access summary. When write is granted, say so explicitly —
// the app will be able to READ and LOG/WRITE health data, not just read.
const writeGranted = v.scope.split(/\s+/).includes(SCOPE_HEALTH_WRITE);
const accessSummary = writeGranted
? `<p><strong>This grants read AND write access:</strong> the application will be able to read your own health records <strong>and log new measurements and mood entries</strong> to your account on your behalf. It cannot delete or change existing entries, edit medications, or reach admin functions.</p>`
: `<p>Scope: <code>${htmlEscape(v.scope)}</code> — read-only access to your own health records.</p>`;
: `<p>Scope: <code>${htmlEscape(v.scope)}</code>. Read-only access to your own health records.</p>`;
// What a read reaches, in plain words, before the person clicks Allow.
// Document text is named on its own because it is the widest thing a
// connection can read (v1.39.3: search and fetch reach it).
@@ -176,7 +176,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (plan.entries.length > MAX_QUEUED_ROWS) {
return apiError(
`The file holds ${plan.entries.length} importable rows — the limit is ${MAX_QUEUED_ROWS} per import`,
`The file holds ${plan.entries.length} importable rows. The limit is ${MAX_QUEUED_ROWS} per import`,
422,
{ errorCode: "too_many_rows" },
);
+1 -1
View File
@@ -66,7 +66,7 @@ async function checkAndWarn(
}
if (auth.via === "query") {
getEvent()?.addWarning(
`withings webhook secret received via legacy URL query — migrate to ${MIGRATION_URL} (re-subscribe with the path-segment token form)`,
`withings webhook secret received via legacy URL query. Migrate to ${MIGRATION_URL} (re-subscribe with the path-segment token form)`,
);
}
return null;
+1 -1
View File
@@ -27,7 +27,7 @@ export default function InsightsActiveEnergyPage() {
yAxisUnit="kcal"
emptyStateIcon={<Flame className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any active energy yet — what's a reasonable daily target, and how does it compare to total calories?"
coachPrefill="I haven't logged any active energy yet. What's a reasonable daily target, and how does it compare to total calories?"
/>
);
}
+1 -1
View File
@@ -26,7 +26,7 @@ export default function InsightsLaermereignissePage() {
yAxisUnit=""
emptyStateIcon={<Volume2 className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any loud-environment events yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any loud-environment events yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -60,7 +60,7 @@ export default function InsightsBlutzuckerPage() {
captureType="BLOOD_GLUCOSE"
targetSummarySlug="blood-glucose"
afterChart={<GlucoseClinicalPanel />}
coachPrefill="I haven't logged any blood glucose yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any blood glucose yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -81,7 +81,7 @@ export default function InsightsBlutdruckPage() {
</Link>
</Button>
}
coachPrefill="I haven't recorded any blood pressure yet — why does it matter, and what should I know before I start?"
coachPrefill="I haven't recorded any blood pressure yet. Why does it matter, and what should I know before I start?"
/>
</SubPageShell>
);
+1 -1
View File
@@ -69,7 +69,7 @@ export default function InsightsBmiPage() {
</Link>
</Button>
}
coachPrefill="I haven't recorded any weight yet — why does BMI matter for me, and what should I know before I start tracking it?"
coachPrefill="I haven't recorded any weight yet. Why does BMI matter for me, and what should I know before I start tracking it?"
/>
</SubPageShell>
);
+1 -1
View File
@@ -24,7 +24,7 @@ export default function InsightsBodyTemperaturePage() {
color="var(--chart-3)"
emptyStateIcon={<Thermometer className="size-6" />}
emptyStateCtaType="BODY_TEMPERATURE"
coachPrefill="I haven't logged any body temperature yet — what's the healthy range, and what should I do if it drifts?"
coachPrefill="I haven't logged any body temperature yet. What's the healthy range, and what should I do if it drifts?"
/>
);
}
+1 -1
View File
@@ -25,7 +25,7 @@ export default function InsightsKoerperwasserPage() {
emptyStateIcon={<Droplet className="size-6" />}
emptyStateCtaType={null}
captureType="TOTAL_BODY_WATER"
coachPrefill="I haven't logged any total body water yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any total body water yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -25,7 +25,7 @@ export default function InsightsKnochenmassePage() {
emptyStateIcon={<Bone className="size-6" />}
emptyStateCtaType={null}
captureType="BONE_MASS"
coachPrefill="I haven't logged any bone mass yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any bone mass yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
@@ -24,7 +24,7 @@ export default function InsightsBreathingDisturbancesPage() {
unitKey="insights.units.breathingEvents"
emptyStateIcon={<Wind className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any sleep-breathing-disturbance data yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any sleep-breathing-disturbance data yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -27,7 +27,7 @@ export default function InsightsCardioFitnessPage() {
statIcon={Gauge}
emptyStateIcon={<Gauge className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any cardio-fitness (VO₂ max) data yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any cardio-fitness (VO₂ max) data yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -26,7 +26,7 @@ export default function InsightsCardioRecoveryPage() {
yAxisUnit="bpm"
emptyStateIcon={<HeartPulse className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any cardio-recovery data yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any cardio-recovery data yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -26,7 +26,7 @@ export default function InsightsTageslichtPage() {
yAxisUnit="min"
emptyStateIcon={<Sun className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any time spent in daylight yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any time spent in daylight yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
@@ -26,7 +26,7 @@ export default function InsightsDoppelstandphasePage() {
yAxisUnit="%"
emptyStateIcon={<Footprints className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any double support time yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any double support time yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
@@ -26,7 +26,7 @@ export default function InsightsLaermbelastungPage() {
yAxisUnit="dBA"
emptyStateIcon={<Volume2 className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any environmental sound exposure yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any environmental sound exposure yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -23,7 +23,7 @@ export default function InsightsFallsPage() {
unitKey="insights.units.falls"
emptyStateIcon={<PersonStanding className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any fall data yet — what does this metric tell me about my health, and how do I lower my fall risk?"
coachPrefill="I haven't logged any fall data yet. What does this metric tell me about my health, and how do I lower my fall risk?"
/>
);
}
+1 -1
View File
@@ -24,7 +24,7 @@ export default function InsightsFettfreieMassePage() {
color="var(--chart-1)"
emptyStateIcon={<Scale className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any fat-free mass yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any fat-free mass yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -24,7 +24,7 @@ export default function InsightsFettmassePage() {
color="var(--chart-5)"
emptyStateIcon={<Droplets className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any fat mass yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any fat mass yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -25,7 +25,7 @@ export default function InsightsStockwerkePage() {
unitKey="insights.units.flights"
emptyStateIcon={<TrendingUp className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any flights climbed yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any flights climbed yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -26,7 +26,7 @@ export default function InsightsKopfhoererpegelPage() {
yAxisUnit="dBA"
emptyStateIcon={<Headphones className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any headphone audio exposure yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any headphone audio exposure yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -37,7 +37,7 @@ export default function InsightsHrvPage() {
yAxisUnit="ms"
emptyStateIcon={<Activity className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any HRV data yet — what does heart-rate variability tell me, and how do I capture it?"
coachPrefill="I haven't logged any HRV data yet. What does heart-rate variability tell me, and how do I capture it?"
// S10 / H1 — device-attributed pointer into the ECG viewer, in the
// HRV / heart context. Self-gates to nothing without recordings.
afterAssessment={<EcgCrossLink />}
+1 -1
View File
@@ -24,7 +24,7 @@ export default function InsightsMagermassePage() {
color="var(--chart-1)"
emptyStateIcon={<Scale className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any lean body mass yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any lean body mass yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -132,7 +132,7 @@ export default function InsightsMoodPageClient() {
<Link href="/mood">{t("insights.emptyState.mood.cta")}</Link>
</Button>
}
coachPrefill="I haven't logged any mood entries yet — why does mood tracking matter, and how should I start?"
coachPrefill="I haven't logged any mood entries yet. Why does mood tracking matter, and how should I start?"
/>
</SubPageShell>
);
+1 -1
View File
@@ -24,7 +24,7 @@ export default function InsightsMuskelmassePage() {
color="var(--chart-1)"
emptyStateIcon={<Dumbbell className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any muscle mass yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any muscle mass yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -37,7 +37,7 @@ export default function InsightsOxygenSaturationPage() {
emptyStateIcon={<Wind className="size-6" />}
emptyStateCtaType={null}
captureType="OXYGEN_SATURATION"
coachPrefill="I haven't logged any oxygen-saturation data yet — what does SpO₂ tell me, and what range should I aim for?"
coachPrefill="I haven't logged any oxygen-saturation data yet. What does SpO₂ tell me, and what range should I aim for?"
/>
);
}
@@ -26,7 +26,7 @@ export default function InsightsPulswellengeschwindigkeitPage() {
yAxisUnit="m/s"
emptyStateIcon={<Activity className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any pulse wave velocity yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any pulse wave velocity yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -114,7 +114,7 @@ export default function InsightsPulsPage() {
</Link>
</Button>
}
coachPrefill="I haven't recorded any resting pulse yet — why does it matter, and what should I know before I start?"
coachPrefill="I haven't recorded any resting pulse yet. Why does it matter, and what should I know before I start?"
/>
</SubPageShell>
);
+1 -1
View File
@@ -25,7 +25,7 @@ export default function InsightsAtemfrequenzPage() {
unitKey="insights.units.respiratoryRate"
emptyStateIcon={<Wind className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any respiratory rate yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any respiratory rate yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -25,7 +25,7 @@ export default function InsightsSixMinuteWalkPage() {
yAxisUnit="m"
emptyStateIcon={<Footprints className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any six-minute-walk data yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any six-minute-walk data yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -32,7 +32,7 @@ export default function InsightsHauttemperaturPage() {
color="var(--chart-5)"
emptyStateIcon={<Thermometer className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any skin temperature yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any skin temperature yet. What does this metric tell me about my health, and how do I improve it?"
afterChart={
<ConnectedDeviceScoreTile
type="BODY_TEMPERATURE_DEVIATION"
+1 -1
View File
@@ -67,7 +67,7 @@ export default function InsightsSchlafPage() {
</Link>
</Button>
}
coachPrefill="I don't have any sleep data yet — why does sleep tracking matter, and what should I know before I connect a source?"
coachPrefill="I don't have any sleep data yet. Why does sleep tracking matter, and what should I know before I connect a source?"
/>
</SubPageShell>
);
+1 -1
View File
@@ -25,7 +25,7 @@ export default function InsightsStairAscentSpeedPage() {
yAxisUnit="m/s"
emptyStateIcon={<Gauge className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any stair-ascent-speed data yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any stair-ascent-speed data yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
@@ -25,7 +25,7 @@ export default function InsightsStairDescentSpeedPage() {
yAxisUnit="m/s"
emptyStateIcon={<Gauge className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any stair-descent-speed data yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any stair-descent-speed data yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -26,7 +26,7 @@ export default function InsightsSchrittlaengePage() {
yAxisUnit="m"
emptyStateIcon={<Footprints className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any walking step length yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any walking step length yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -31,7 +31,7 @@ export default function InsightsStepsPage() {
emptyStateIcon={<Footprints className="size-6" />}
emptyStateCtaType={null}
captureType="ACTIVITY_STEPS"
coachPrefill="I haven't logged any steps yet — what's a reasonable daily step target, and how does walking more help my health?"
coachPrefill="I haven't logged any steps yet. What's a reasonable daily step target, and how does walking more help my health?"
/>
);
}
+1 -1
View File
@@ -25,7 +25,7 @@ export default function InsightsGefaessalterPage() {
unitKey="insights.units.years"
emptyStateIcon={<HeartPulse className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any vascular age yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any vascular age yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -26,7 +26,7 @@ export default function InsightsViszeralfettPage() {
yAxisUnit=""
emptyStateIcon={<Droplets className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any visceral fat yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any visceral fat yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -26,7 +26,7 @@ export default function InsightsGangasymmetriePage() {
yAxisUnit="%"
emptyStateIcon={<Footprints className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any walking asymmetry yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any walking asymmetry yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -24,7 +24,7 @@ export default function InsightsGehstreckePage() {
color="var(--chart-2)"
emptyStateIcon={<Footprints className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any walking and running distance yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any walking and running distance yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -26,7 +26,7 @@ export default function InsightsGehpulsPage() {
yAxisUnit="bpm"
emptyStateIcon={<HeartPulse className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any walking heart rate average yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any walking heart rate average yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -24,7 +24,7 @@ export default function InsightsGehgeschwindigkeitPage() {
color="var(--chart-2)"
emptyStateIcon={<Gauge className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any walking speed yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any walking speed yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -26,7 +26,7 @@ export default function InsightsGangstabilitaetPage() {
yAxisUnit="%"
emptyStateIcon={<Gauge className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any walking steadiness yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any walking steadiness yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -129,7 +129,7 @@ export default function InsightsGewichtPage() {
</Link>
</Button>
}
coachPrefill="I haven't recorded any weight yet — why does it matter, and what should I know before I start tracking?"
coachPrefill="I haven't recorded any weight yet. Why does it matter, and what should I know before I start tracking?"
/>
</SubPageShell>
);
+1 -1
View File
@@ -132,7 +132,7 @@ export default function InsightsWorkoutsPageClient() {
</Button>
) : null
}
coachPrefill="I haven't logged any workouts yet — why does tracking them matter, and what should I focus on first?"
coachPrefill="I haven't logged any workouts yet. Why does tracking them matter, and what should I focus on first?"
/>
) : (
<>
+1 -1
View File
@@ -24,7 +24,7 @@ export default function InsightsWristTemperaturePage() {
color="var(--chart-5)"
emptyStateIcon={<Thermometer className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any wrist-temperature data yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any wrist-temperature data yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+2 -2
View File
@@ -21,7 +21,7 @@ const inter = Inter({
export const metadata: Metadata = {
title: "HealthLog",
description:
"Self-hosted health tracker — weight, blood pressure, glucose, mood, medications. Withings + Apple Health sync, transparent derived wellness metrics, AI Insights you own.",
"Self-hosted health tracker: weight, blood pressure, glucose, mood, medications. Withings + Apple Health sync, transparent derived wellness metrics, AI Insights you own.",
manifest: "/manifest.json",
icons: {
icon: "/favicon.svg",
@@ -51,7 +51,7 @@ export const metadata: Metadata = {
url: "/logo-readme.png",
width: 1000,
height: 1000,
alt: "HealthLog — your health data, your server",
alt: "HealthLog: your health data, your server",
},
],
},
+1 -1
View File
@@ -50,7 +50,7 @@ const LAST_UPDATED_EN = new Intl.DateTimeFormat("en-GB", {
}).format(LAST_UPDATED_DATE);
export const metadata: Metadata = {
title: "Datenschutzerklärung / Privacy Policy — HealthLog",
title: "Datenschutzerklärung / Privacy Policy | HealthLog",
description:
"Wie HealthLog personenbezogene Gesundheitsdaten verarbeitet, Auftragsverarbeiter, DSGVO-Rechte und die EU-MDR-Medizinprodukte-Grenze. Bilingual DE/EN.",
robots: { index: true, follow: true },
@@ -72,7 +72,7 @@ describe("chart isError rendering — error state, not empty state", () => {
expect(html).toContain("Pulse");
// The retry button carries the chart title in its accessible name
// so several failed charts on one page stay distinguishable.
expect(html).toContain('aria-label="Erneut versuchen – Pulse"');
expect(html).toContain('aria-label="Erneut versuchen: Pulse"');
// The empty-state copy must NOT paint — an outage is not "no data".
expect(html).not.toContain("Keine Daten in diesem Zeitraum");
@@ -169,7 +169,7 @@ describe("<ChartErrorState> — announcement, sizing, accessible name", () => {
it("joins the action context into the retry button's accessible name", async () => {
const html = await renderState({ actionContext: "Pulse" });
expect(html).toContain('aria-label="Retry – Pulse"');
expect(html).toContain('aria-label="Retry: Pulse"');
});
it("omits the aria-label without a context so the label is the name", async () => {
+1 -1
View File
@@ -82,7 +82,7 @@ export function ChartErrorState({
size="sm"
onClick={onAction}
aria-label={
actionContext ? `${actionLabel} – ${actionContext}` : undefined
actionContext ? `${actionLabel}: ${actionContext}` : undefined
}
>
<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />
@@ -152,7 +152,7 @@ export function IllnessSection({
: null,
t(`illness.type.${episode.type}`),
t(`illness.lifecycle.${episode.lifecycle}`),
`${fmtDate(episode.onsetAt)} – ${
`${fmtDate(episode.onsetAt)}–${
episode.resolvedAt
? fmtDate(episode.resolvedAt)
: t("doctorReport.illnessOngoing")
+1 -1
View File
@@ -357,7 +357,7 @@ export function MedicationsSection({
return (
<Fragment key={med.id}>
<StatRow
label={med.dose ? `${med.name} — ${med.dose}` : med.name}
label={med.dose ? `${med.name} ${med.dose}` : med.name}
value={
rate
? t("clinicianView.adherence", { rate })
@@ -206,7 +206,7 @@ export function DoseLogSection({
{shown.map((dose, index) => (
<StatRow
key={`${dose.medicationName}-${dose.effectiveAt}-${index}`}
label={`${fmtDateTime(dose.effectiveAt)} — ${dose.medicationName}`}
label={`${fmtDateTime(dose.effectiveAt)}: ${dose.medicationName}`}
value={
(dose.status === "completed"
? t("doctorReport.colTaken")
+2 -2
View File
@@ -99,7 +99,7 @@ export function PredictionsPanel({
<Row
hue={FERTILE_HUE}
label={t("cycle.predictions.fertileWindow")}
value={`${formatDate(prediction.fertileWindowStart, fmt)} – ${formatDate(
value={`${formatDate(prediction.fertileWindowStart, fmt)}–${formatDate(
prediction.fertileWindowEnd,
fmt,
)}`}
@@ -264,7 +264,7 @@ function HistoryCard({
>
<span className="text-foreground tabular-nums">
{formatDate(c.startDate, fmt)}
{" – "}
{"–"}
{c.endDate
? formatDate(c.endDate, fmt)
: t("cycle.history.ongoing")}
@@ -125,7 +125,7 @@ export function AchievementUnlockNotifier({
// card) so the surprise lands.
if (achievement.isHidden) {
toast(t("achievements.hiddenUnlockToast.title"), {
description: `${t(achievement.titleKey)} — ${t(achievement.descriptionKey)}`,
description: `${t(achievement.titleKey)}: ${t(achievement.descriptionKey)}`,
icon: <Sparkles className="size-4" />,
duration: 8000,
});
@@ -194,7 +194,7 @@ describe("<CoincidentDeviationCard>", () => {
expect(html).toContain('data-state="fired"');
expect(html).toContain('data-slot="coincident-factors"');
// The load-bearing framing line.
expect(html).toContain("Possible factors — never a cause");
expect(html).toContain("Possible factors (never a cause)");
expect(html).toContain("not a diagnosis");
// At most amber — never destructive/red.
expect(html).toContain("border-warning");
@@ -226,7 +226,7 @@ describe("<CoincidentDeviationCard>", () => {
expect(html).toContain('data-state="fired"');
// The reframe line replaces the open-ended "possible factors" copy.
expect(html).toContain("the condition you logged");
expect(html).not.toContain("Possible factors — never a cause");
expect(html).not.toContain("Possible factors (never a cause)");
// Annotate, don't escalate: no new destructive colour beyond the amber the
// fired state already carries.
expect(html).not.toContain("text-destructive");
@@ -282,8 +282,8 @@ describe("<MetricTargetSummary>", () => {
expect(html).toContain("Source: ADA 2024 / DDG");
// The i18n label keys resolve to EN context headings (rendered
// upper-cased by the heading style but kept as text content).
expect(html).toContain("Glucose — fasting");
expect(html).toContain("Glucose — post-meal");
expect(html).toContain("Glucose (fasting)");
expect(html).toContain("Glucose (post-meal)");
});
it("converts the glucose bands to mmol/L when the profile prefers it", () => {
@@ -476,7 +476,7 @@ function SortableSectionRow({
type="button"
{...attributes}
{...listeners}
aria-label={`${labels.dragHandle} — ${title}`}
aria-label={`${labels.dragHandle}: ${title}`}
title={labels.dragHandle}
disabled={disabled}
data-slot="insights-edit-section-handle"
@@ -507,7 +507,7 @@ function SortableSectionRow({
<EyeToggle
visible={section.visible}
disabled={disabled || gatedOff}
label={`${section.visible ? labels.hide : labels.show} — ${title}`}
label={`${section.visible ? labels.hide : labels.show}: ${title}`}
onClick={() => onToggle(section.id, !section.visible)}
slot="insights-edit-section-eye"
/>
@@ -316,7 +316,7 @@ export function MetricStatStrip({
role="group"
aria-label={
seriesLabel
? `${t("insights.subPage.stats.label")} — ${seriesLabel}`
? `${t("insights.subPage.stats.label")}: ${seriesLabel}`
: t("insights.subPage.stats.label")
}
className="gap-2 py-3 md:py-4"
@@ -140,5 +140,5 @@ export function formatDateRange(
minute: "2-digit",
...hourCycleOptions(timeFormat),
});
return `${dateFmt.format(start)} · ${timeFmt.format(start)} – ${timeFmt.format(end)}`;
return `${dateFmt.format(start)}, ${timeFmt.format(start)}–${timeFmt.format(end)}`;
}
@@ -54,14 +54,14 @@ describe("<OfflineBanner>", () => {
// the honest thing.
const en = loadMessages("en");
expect(en.offlineBanner.message).toBe(
"No connection — changes can't be saved until you're back online.",
"No connection: changes can't be saved until you're back online.",
);
});
it("ships the DE copy for `offlineBanner.message`", () => {
const de = loadMessages("de");
expect(de.offlineBanner.message).toBe(
"Keine Verbindung — Änderungen können erst gespeichert werden, wenn du wieder online bist.",
"Keine Verbindung: Änderungen können erst gespeichert werden, wenn du wieder online bist.",
);
});
@@ -105,7 +105,7 @@ describe("<InjectionSitePicker>", () => {
// Exactly one site is recommended → exactly one composed label.
const composed =
html.match(/aria-label="[^"]*— recommended next site"/g) ?? [];
html.match(/aria-label="[^"]*: recommended next site"/g) ?? [];
expect(composed.length).toBe(1);
});
@@ -153,7 +153,7 @@ describe("<InjectionSitePicker>", () => {
// empty history the recommender lands on ABDOMEN_LEFT, so its label
// folds in the "recommended next site" cue.
expect(html).toContain(
'aria-label="Abdomen, lower left — recommended next site"',
'aria-label="Abdomen, lower left: recommended next site"',
);
expect(html).toContain('aria-label="Abdomen, lower right"');
expect(html).toContain('aria-label="Abdomen, upper left"');
@@ -173,7 +173,7 @@ describe("<InjectionSitePicker>", () => {
);
expect(html).toContain(
'aria-label="Bauch, unten links — empfohlene nächste Stelle"',
'aria-label="Bauch, unten links: empfohlene nächste Stelle"',
);
expect(html).toContain('aria-label="Bauch, unten rechts"');
expect(html).toContain('aria-label="Bauch, oben links"');
@@ -7,7 +7,7 @@
* - every column renders from the SAME payloads the cards consume
* (the list row's `nextDueAt` / `stockDosesRemaining`, the batched
* compliance-summary cache entry the cards share);
* - the action buttons are per-row labelled ("Take – {name}") and the
* - the action buttons are per-row labelled ("Take {name}") and the
* row rides the cards' shared intake hook (source-level guard);
* - tri-state sorting semantics incl. `aria-sort` and null-last
* ordering, with `null` sort = the manual order the page passed in;
@@ -254,8 +254,8 @@ describe("<MedicationTable> — structure + shared payloads", () => {
client,
);
expect(html).toContain('aria-label="Take – Ramipril"');
expect(html).toContain('aria-label="Skip – Ramipril"');
expect(html).toContain('aria-label="Take Ramipril"');
expect(html).toContain('aria-label="Skip Ramipril"');
// 44-px mobile floor on the action buttons (shrinks at sm).
expect(html).toContain("size-11 sm:size-9");
});
@@ -345,7 +345,7 @@ describe("<MedicationTable> — structure + shared payloads", () => {
expect(html).toContain("opacity-60");
expect(html).toContain("Inactive");
// No take/skip affordance on the inactive row.
expect(html).not.toContain('aria-label="Take – Amoxicillin"');
expect(html).not.toContain('aria-label="Take Amoxicillin"');
});
// v1.29.x — the Today digest's "Log dose" deep-link
@@ -407,7 +407,7 @@ describe("<MedicationTable> — structure + shared payloads", () => {
);
expect(html).toContain("Mounjaro");
expect(html).toContain('aria-label="Take – Mounjaro"');
expect(html).toContain('aria-label="Take Mounjaro"');
expect(html).toContain("4 doses");
});
@@ -436,12 +436,12 @@ describe("<MedicationTable> — structure + shared payloads", () => {
client,
);
expect(html).not.toContain('aria-label="Take – Amoxicillin"');
expect(html).not.toContain('aria-label="Skip – Amoxicillin"');
expect(html).not.toContain('aria-label="Take Amoxicillin"');
expect(html).not.toContain('aria-label="Skip Amoxicillin"');
expect(html).toContain('data-slot="medication-table-course-ended-marker"');
expect(html).toContain(">Ended<");
expect(html).toContain('aria-label="Take – Prednisolone"');
expect(html).toContain('aria-label="Skip – Prednisolone"');
expect(html).toContain('aria-label="Take Prednisolone"');
expect(html).toContain('aria-label="Skip Prednisolone"');
});
it("renders an as-needed row: calm marker, no compliance bars (v1.16.11 #316)", () => {
@@ -476,7 +476,7 @@ describe("<MedicationTable> — structure + shared payloads", () => {
// Compliance column shows the en-dash, not bars or a skeleton.
expect(html).not.toContain("Adherence (");
// The quick actions stay — an as-needed dose is loggable inline.
expect(html).toContain('aria-label="Take – Ibuprofen"');
expect(html).toContain('aria-label="Take Ibuprofen"');
// Stock column still renders (inventory tracking works for PRN).
expect(html).toContain("10 doses");
});
@@ -292,7 +292,7 @@ export function LogIntakeDialog({
>
{medications.map((m) => (
<option key={m.id} value={m.id}>
{m.dose ? `${m.name} — ${formatDose(m.dose, t)}` : m.name}
{m.dose ? `${m.name} ${formatDose(m.dose, t)}` : m.name}
</option>
))}
</NativeSelect>
@@ -355,7 +355,7 @@ function SortableMedicationRow({
type="button"
{...attributes}
{...listeners}
aria-label={`${labels.dragHandle} — ${medication.name}`}
aria-label={`${labels.dragHandle}: ${medication.name}`}
aria-describedby={dragHintId}
title={labels.dragHandle}
disabled={disabled}
@@ -380,7 +380,7 @@ function SortableMedicationRow({
className="size-11 sm:size-9"
onClick={() => onMove(medication.id, -1)}
disabled={index === 0 || disabled}
aria-label={`${labels.moveUp} — ${medication.name}`}
aria-label={`${labels.moveUp}: ${medication.name}`}
>
<ArrowUp className="h-4 w-4" />
</Button>
@@ -391,7 +391,7 @@ function SortableMedicationRow({
className="size-11 sm:size-9"
onClick={() => onMove(medication.id, 1)}
disabled={index === total - 1 || disabled}
aria-label={`${labels.moveDown} — ${medication.name}`}
aria-label={`${labels.moveDown}: ${medication.name}`}
>
<ArrowDown className="h-4 w-4" />
</Button>
@@ -216,7 +216,7 @@ export function ScheduleHistoryTimeline({
local day (issue #490 audit). */}
<p className="text-muted-foreground text-xs tabular-nums">
{fmt.date(revision.validFrom)}
{" – "}
{"–"}
{fmt.date(revision.validUntil)}
</p>
</div>
@@ -132,7 +132,7 @@ export function InstrumentCard({
type="button"
onClick={onOpenDetail}
data-slot="instrument-card-open"
aria-label={`${title} — ${t("mentalHealth.openDetail")}`}
aria-label={`${title}: ${t("mentalHealth.openDetail")}`}
className="focus-visible:ring-ring block w-full rounded-t-xl text-left focus-visible:ring-2 focus-visible:outline-none"
>
<MedicationCardHeader
@@ -180,7 +180,7 @@ function SortableRow({
type="button"
{...attributes}
{...listeners}
aria-label={`${labels.dragHandle} — ${item.name}`}
aria-label={`${labels.dragHandle}: ${item.name}`}
aria-describedby={dragHintId}
title={labels.dragHandle}
data-slot="module-drag-handle"
@@ -205,7 +205,7 @@ function SortableRow({
className="size-11 sm:size-9"
onClick={() => onMove(item.id, -1)}
disabled={index === 0}
aria-label={`${labels.moveUp} — ${item.name}`}
aria-label={`${labels.moveUp}: ${item.name}`}
>
<ArrowUp className="h-4 w-4" />
</Button>
@@ -216,7 +216,7 @@ function SortableRow({
className="size-11 sm:size-9"
onClick={() => onMove(item.id, 1)}
disabled={index === total - 1}
aria-label={`${labels.moveDown} — ${item.name}`}
aria-label={`${labels.moveDown}: ${item.name}`}
>
<ArrowDown className="h-4 w-4" />
</Button>
@@ -153,7 +153,7 @@ export function ArchivedTagsCard({ catalog }: ArchivedTagsCardProps) {
size="icon"
className="text-muted-foreground hover:text-foreground size-11 sm:size-9"
onClick={() => setPurgeTag(tag)}
aria-label={`${t("mood.manage.purgeAction")} — ${name}`}
aria-label={`${t("mood.manage.purgeAction")}: ${name}`}
title={t("mood.manage.purgeAction")}
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
@@ -432,7 +432,7 @@ function SortableGroupRow({
type="button"
{...attributes}
{...listeners}
aria-label={`${t("dashboard.dragHandle")} — ${name}`}
aria-label={`${t("dashboard.dragHandle")}: ${name}`}
aria-describedby={dragHintId}
title={t("dashboard.dragHandle")}
disabled={disabled}
@@ -461,7 +461,7 @@ function SortableGroupRow({
className="size-11 sm:size-9"
onClick={() => onMove(groupKey, -1)}
disabled={index === 0 || disabled}
aria-label={`${t("mood.manage.moveUp")} — ${name}`}
aria-label={`${t("mood.manage.moveUp")}: ${name}`}
>
<ArrowUp className="h-4 w-4" />
</Button>
@@ -472,7 +472,7 @@ function SortableGroupRow({
className="size-11 sm:size-9"
onClick={() => onMove(groupKey, 1)}
disabled={index === total - 1 || disabled}
aria-label={`${t("mood.manage.moveDown")} — ${name}`}
aria-label={`${t("mood.manage.moveDown")}: ${name}`}
>
<ArrowDown className="h-4 w-4" />
</Button>
@@ -484,7 +484,7 @@ function SortableGroupRow({
variant="ghost"
size="icon"
className="size-11 sm:size-9"
aria-label={`${t("common.moreOptions")} — ${name}`}
aria-label={`${t("common.moreOptions")}: ${name}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
@@ -546,7 +546,7 @@ function SortableTagRow({
type="button"
{...attributes}
{...listeners}
aria-label={`${t("dashboard.dragHandle")} — ${name}`}
aria-label={`${t("dashboard.dragHandle")}: ${name}`}
aria-describedby={dragHintId}
title={t("dashboard.dragHandle")}
disabled={disabled}
@@ -589,7 +589,7 @@ function SortableTagRow({
: isHidden
? t("mood.manage.showTag")
: t("mood.manage.hideTag")
} — ${name}`}
}: ${name}`}
title={
tag.custom
? t("mood.manage.archive")
@@ -613,7 +613,7 @@ function SortableTagRow({
className="size-11 sm:size-9"
onClick={() => onMove(groupKey, tag.key, -1)}
disabled={index === 0 || disabled}
aria-label={`${t("mood.manage.moveUp")} — ${name}`}
aria-label={`${t("mood.manage.moveUp")}: ${name}`}
>
<ArrowUp className="h-4 w-4" />
</Button>
@@ -624,7 +624,7 @@ function SortableTagRow({
className="size-11 sm:size-9"
onClick={() => onMove(groupKey, tag.key, 1)}
disabled={index === total - 1 || disabled}
aria-label={`${t("mood.manage.moveDown")} — ${name}`}
aria-label={`${t("mood.manage.moveDown")}: ${name}`}
>
<ArrowDown className="h-4 w-4" />
</Button>
@@ -635,7 +635,7 @@ function SortableTagRow({
variant="ghost"
size="icon"
className="size-11 sm:size-9"
aria-label={`${t("common.moreOptions")} — ${name}`}
aria-label={`${t("common.moreOptions")}: ${name}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
@@ -336,8 +336,8 @@ describe("<DashboardLayoutSection> — native-only widget group (issue #581)", (
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
expect(html).toContain('data-widget-id="bmi"');
expect(html).toContain('aria-label="BMI — Tile"');
expect(html).toContain('aria-label="BMI — Chart"');
expect(html).toContain('aria-label="BMI: Tile"');
expect(html).toContain('aria-label="BMI: Chart"');
// Reflects the stored flags rather than inventing a default.
const row = html.match(
/data-widget-id="bmi"[\s\S]*?(?=data-slot="native-widget-row"|$)/,
@@ -448,7 +448,7 @@ describe("<DashboardLayoutSection> — iOS-pin-only ids hidden from web (v1.11.2
html.indexOf('data-slot="native-only-widgets"'),
);
for (const id of IOS_PIN_ONLY_WIDGET_IDS) {
expect(sortableList).not.toContain(`${pinOnlyLabels[id]} — `);
expect(sortableList).not.toContain(`${pinOnlyLabels[id]}: `);
// ...and each is reachable exactly once, in the native-only group.
expect(html).toContain(`data-widget-id="${id}"`);
}
@@ -465,11 +465,11 @@ describe("<DashboardLayoutSection> — iOS-pin-only ids hidden from web (v1.11.2
describe("<DashboardLayoutSection> — disabled-module widget toggles", () => {
// The achievements widget label drives its switch aria-label; it is the
// canonical disabled-module probe (label resolves to "Achievements").
const ACHIEVEMENTS_ARIA = "Achievements — ";
const ACHIEVEMENTS_ARIA = "Achievements: ";
// Mood is a module-owned widget too; weight is a core widget with NO module
// entry, so it must survive any module-off state.
const MOOD_ARIA = "Mood — ";
const WEIGHT_ARIA = "Weight — ";
const MOOD_ARIA = "Mood: ";
const WEIGHT_ARIA = "Weight: ";
it("hides a widget toggle whose owning module is disabled", () => {
authState.modules = { achievements: false };
@@ -105,7 +105,7 @@ describe("IntegrationStatusPill", () => {
<IntegrationStatusPill state="parked" lastSyncAt={null} />,
);
expect(html).toContain("Paused");
expect(html).toContain("Paused — reconnect");
expect(html).toContain("Paused: reconnect");
expect(html).toContain('data-state="parked"');
});
@@ -115,7 +115,7 @@ describe("IntegrationStatusPill", () => {
"de",
);
expect(html).toContain("Pausiert");
expect(html).toContain("Pausiert — neu verbinden");
expect(html).toContain("Pausiert: neu verbinden");
});
// The three states the server verdict added. `stale` and `stalled` both
@@ -233,7 +233,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
// The pill flips to `data-state="parked"` and the resume banner
// renders with the reconnect CTA. The legacy `error` reconnect text
// (red) must NOT appear because the user-facing copy is the lighter
// "Paused — reconnect" string, not "Error — reconnect".
// "Paused: reconnect" string, not "Error — reconnect".
it("Withings parked state surfaces the parked pill + resume CTA", () => {
setIntegrationStatus({
threshold: 3,
@@ -256,7 +256,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
const html = render();
expect(html).toContain('data-state="parked"');
expect(html).toContain("Paused");
expect(html).toContain("Paused — reconnect");
expect(html).toContain("Paused: reconnect");
expect(html).toContain('data-testid="withings-parked-banner"');
expect(html).toContain('data-testid="withings-resume-button"');
// The lastError is surfaced under the pill so the operator sees
@@ -190,9 +190,9 @@ describe("<MoodSection> — SSR smoke", () => {
const groupRows = html.match(/data-slot="mood-group-row"/g);
expect(groupRows?.length ?? 0).toBe(2);
// One kebab per CUSTOM group row; the seeded row carries none.
const kebabs = html.match(/More options — Garten/g);
const kebabs = html.match(/More options: Garten/g);
expect(kebabs?.length ?? 0).toBeGreaterThanOrEqual(1);
expect(html).not.toContain("More options — Feelings");
expect(html).not.toContain("More options: Feelings");
});
it("renders custom tags by decrypted label, never the raw key", () => {
@@ -206,7 +206,7 @@ describe("<MoodSection> — SSR smoke", () => {
expect(html).toMatch(
/data-slot="mood-tag-manage-row"[^>]*data-hidden="true"/,
);
expect(html).toContain("Show tag — stressed");
expect(html).toContain("Show tag: stressed");
});
it("surfaces the usage count badge", () => {
@@ -219,7 +219,7 @@ describe("<MoodSection> — SSR smoke", () => {
expect(html).toContain('data-slot="mood-archived-row"');
expect(html).toContain("Altes Tag");
expect(html).toContain("Restore");
expect(html).toContain("Delete permanently — Altes Tag");
expect(html).toContain("Delete permanently: Altes Tag");
// The active custom tag must NOT appear in the archived card.
const archivedCard = html.slice(
html.indexOf('data-slot="mood-archived-tags-card"'),
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff. Show more