mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
refactor(i18n): a description is one sentence, in all six languages
Measured before the sweep: 317 description and hint strings under the `settings` and `admin` namespaces, median 83 characters, 113 of them two sentences or more. The rule the design standards now carry is one sentence, guideline 120 characters, and this is the sweep that makes it true. Almost nothing was retranslated. A second sentence is already written in every language — it is just in the wrong slot. `scripts/split-description.mjs` cuts a description at its first sentence boundary in all six bundles at once and either drops the tail or files it under its own key; the twenty-one keys that moved now render in the card body as foreground `text-sm`, which is where multi-sentence prose belongs and where a reader can actually read it (the slot is muted `text-xs`). Eight tails were dropped outright, because "Off by default" under a switch, "Topics and frequency are adjustable" above the topic switches, and "Only admins can create invitations" on an admin-gated route are not information. Twenty-eight keys are gone: the privacy page's intro, which was a table of contents for the four cards under it; two About descriptions that listed verbatim the labels and the buttons beneath them; a card description that repeated its own title; the API-token sentence that said what the paragraph below it said with the actual instruction; and the eight integration secondary sentences. Six descriptions were rewritten shorter in all six languages where trimming was honest rather than lossy. On the strings that changed: 4391 characters of English down to 2290, and 32 multi-sentence descriptions down to zero. The rule is now a gate. `card-description-length.test.ts` reads the keys that actually reach a card header's description slot — statically, from the JSX — and checks them in EN and DE, because a rule that only holds in English produces a slot that wraps to two lines in half the locales. Its exception list is empty and carries a reason requirement plus a test that removes a stale entry. Verified by putting a second sentence back: it fails.
This commit is contained in:
1 parent
330f60115a
commit
ba0cd63cf5
33 files changed
+779
-392
No files matched your search
@@ -133,6 +133,9 @@ export function AppLogPreviewSection() {
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""} motion-reduce:animate-none`}
|
||||
/>
|
||||
<p className="text-sm">
|
||||
{t("admin.section.app-logs.processDetail")}
|
||||
</p>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -97,6 +97,8 @@ export function DangerZoneSection() {
|
||||
title={t("admin.deleteAllData")}
|
||||
description={t("admin.deleteAllDescription")}
|
||||
/>
|
||||
<p className="text-sm">{t("admin.deleteAllDetail")}</p>
|
||||
|
||||
{wipeMsg && (
|
||||
<p
|
||||
className={`mt-3 text-sm ${
|
||||
|
||||
@@ -115,6 +115,10 @@ export function EncryptionSection() {
|
||||
title={t("admin.section.encryption.coverageTitle")}
|
||||
description={t("admin.section.encryption.coverageDescription")}
|
||||
/>
|
||||
<p className="text-sm">
|
||||
{t("admin.section.encryption.coverageDetail")}
|
||||
</p>
|
||||
|
||||
{statusQuery.isLoading ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
|
||||
@@ -94,6 +94,8 @@ export function ModuleAvailabilitySection() {
|
||||
title={t("admin.modules.title")}
|
||||
description={t("admin.modules.description")}
|
||||
/>
|
||||
<p className="text-sm">{t("admin.modules.detail")}</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{MODULE_KEYS
|
||||
// A module switched off in code (pending a rebuild) is hard-off
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* The description slot holds ONE sentence, guideline ≤ 120 characters
|
||||
* (design standards §3).
|
||||
*
|
||||
* Scope is deliberately the strings that actually reach a card header's
|
||||
* description slot — `<SettingsCardHeader description={t("…")}>` — because
|
||||
* that is the slot the rule is about. A hint in a card body is prose and is
|
||||
* allowed to be prose; `scripts/measure-description-lengths.mjs` sweeps those
|
||||
* for review without gating them.
|
||||
*
|
||||
* Both EN and DE are checked. DE runs 15-25% longer than EN, and a rule that
|
||||
* only holds in English produces a slot that wraps to two lines in half the
|
||||
* locales — which is the same defect, moved.
|
||||
*/
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const MAX_CHARS = 120;
|
||||
|
||||
/**
|
||||
* Strings that stay longer than the guideline, each with the reason. The
|
||||
* guideline is not a hard cut: a sentence that needs 130 characters to stay
|
||||
* true is fine. Two sentences never are, and that half of the rule has no
|
||||
* exceptions.
|
||||
*/
|
||||
const LENGTH_EXCEPTIONS: Record<string, string> = {
|
||||
// Empty on purpose: the sweep that introduced this guard left nothing over
|
||||
// the guideline in EN or DE. An entry here needs a sentence saying what the
|
||||
// extra characters buy, and `carries no stale exception` deletes it again
|
||||
// the moment the string fits or stops being a description.
|
||||
};
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
if (entry === "__tests__" || entry === "node_modules") continue;
|
||||
walk(full, out);
|
||||
} else if (entry.endsWith(".tsx")) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every `description={t("…")}` inside a `<SettingsCardHeader …>` element. */
|
||||
function describedKeys(): Map<string, string[]> {
|
||||
const keys = new Map<string, string[]>();
|
||||
for (const dir of ["settings", "admin", "measurement-reminders"]) {
|
||||
for (const file of walk(join(ROOT, "src", "components", dir))) {
|
||||
const source = readFileSync(file, "utf8");
|
||||
const header = /<SettingsCardHeader\b/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = header.exec(source))) {
|
||||
// The element ends at the first `/>` or `>` that closes the opening
|
||||
// tag; a `description={t("x")}` after that belongs to something else.
|
||||
const end = source.indexOf("/>", match.index);
|
||||
const slice = source.slice(match.index, end === -1 ? undefined : end);
|
||||
const desc = /description=\{t\(\s*"([^"]+)"/.exec(slice);
|
||||
if (!desc) continue;
|
||||
const list = keys.get(desc[1]) ?? [];
|
||||
list.push(file.slice(ROOT.length + 1));
|
||||
keys.set(desc[1], list);
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function resolve(bundle: unknown, key: string): string | undefined {
|
||||
const value = key
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(node, part) =>
|
||||
node && typeof node === "object"
|
||||
? (node as Record<string, unknown>)[part]
|
||||
: undefined,
|
||||
bundle,
|
||||
);
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
/** Sentence terminators followed by a space or the end of the string. */
|
||||
function sentenceCount(text: string): number {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return 0;
|
||||
const matches = trimmed.match(/[.!?](\s+\S|$)/g);
|
||||
return Math.max(1, matches ? matches.length : 1);
|
||||
}
|
||||
|
||||
describe("card header descriptions — one sentence, ≤120 characters", () => {
|
||||
const keys = describedKeys();
|
||||
|
||||
it("finds the description slots it is meant to check", () => {
|
||||
// A matcher that quietly matches nothing passes every assertion below it.
|
||||
expect(keys.size).toBeGreaterThan(25);
|
||||
});
|
||||
|
||||
for (const locale of ["en", "de"] as const) {
|
||||
const bundle = JSON.parse(
|
||||
readFileSync(join(ROOT, "messages", `${locale}.json`), "utf8"),
|
||||
);
|
||||
|
||||
it(`${locale}: every description is a single sentence`, () => {
|
||||
const offenders: string[] = [];
|
||||
for (const [key, files] of keys) {
|
||||
const value = resolve(bundle, key);
|
||||
if (value === undefined) continue; // the i18n coverage guard owns this
|
||||
if (sentenceCount(value) > 1) {
|
||||
offenders.push(`${key} (${sentenceCount(value)} sentences) — ${files[0]}`);
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it(`${locale}: every description is within the length guideline`, () => {
|
||||
const offenders: string[] = [];
|
||||
for (const [key, files] of keys) {
|
||||
const value = resolve(bundle, key);
|
||||
if (value === undefined) continue;
|
||||
if (value.length > MAX_CHARS && !(key in LENGTH_EXCEPTIONS)) {
|
||||
offenders.push(`${key} (${value.length} chars) — ${files[0]}`);
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
it("carries no stale exception", () => {
|
||||
const bundle = JSON.parse(
|
||||
readFileSync(join(ROOT, "messages", "en.json"), "utf8"),
|
||||
);
|
||||
for (const [key, reason] of Object.entries(LENGTH_EXCEPTIONS)) {
|
||||
expect(keys.has(key), `${key} is allowlisted but no longer a description`)
|
||||
.toBe(true);
|
||||
expect(
|
||||
resolve(bundle, key)!.length,
|
||||
`${key} is allowlisted but now fits the guideline`,
|
||||
).toBeGreaterThan(MAX_CHARS);
|
||||
expect(reason.length).toBeGreaterThan(20);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -55,7 +55,7 @@ describe("<CoachSection> — SSR smoke", () => {
|
||||
expect(html).toContain('data-testid="settings-disable-coach-switch"');
|
||||
// v1.18.1 (D7) — polarity flipped to activate/default-on.
|
||||
expect(html).toContain("Activate Coach");
|
||||
expect(html).toContain("Show the Coach button and drawer. On by default");
|
||||
expect(html).toContain("Show the Coach button and drawer");
|
||||
// v1.18.6 (W9) — the proactive Coach nudge card moved here from
|
||||
// Benachrichtigungen; its anchor renders under the enabled-coach gate.
|
||||
expect(html).toContain('id="coach-nudge"');
|
||||
@@ -68,7 +68,6 @@ describe("<CoachSection> — SSR smoke", () => {
|
||||
authState.disableCoach = false;
|
||||
const html = render("de");
|
||||
expect(html).toContain("Coach aktivieren");
|
||||
expect(html).toContain("Standardmäßig an");
|
||||
expect(html).not.toContain("settings.sections.coach.");
|
||||
expect(html).not.toContain("settings.coach.activate.");
|
||||
});
|
||||
|
||||
@@ -150,6 +150,6 @@ describe("Settings — DisableCoachCard", () => {
|
||||
const html = render();
|
||||
// v1.18.1 (D7) — polarity flipped to activate/default-on.
|
||||
expect(html).toContain("Activate Coach");
|
||||
expect(html).toContain("Show the Coach button and drawer. On by default");
|
||||
expect(html).toContain("Show the Coach button and drawer");
|
||||
});
|
||||
});
|
||||
@@ -194,11 +194,7 @@ export function AboutSection() {
|
||||
which read as a separate field even though the two values belong
|
||||
on the same row. */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={Info}
|
||||
title="HealthLog"
|
||||
description={t("settings.about.identityDescription")}
|
||||
/>
|
||||
<SettingsCardHeader icon={Info} title="HealthLog" />
|
||||
|
||||
{isLoading || !version ? (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -271,7 +267,6 @@ export function AboutSection() {
|
||||
<SettingsCardHeader
|
||||
icon={BookOpen}
|
||||
title={t("settings.about.linksHeading")}
|
||||
description={t("settings.about.linksDescription")}
|
||||
/>
|
||||
<SettingsCardActions align="start">
|
||||
<Button
|
||||
|
||||
@@ -68,6 +68,8 @@ export function GrantsReceivedCard() {
|
||||
title={t("recordSharing.received.title")}
|
||||
description={t("recordSharing.received.description")}
|
||||
/>
|
||||
<p className="text-sm">{t("recordSharing.received.detail")}</p>
|
||||
|
||||
<div>
|
||||
{isLoading && (
|
||||
<Loader2
|
||||
|
||||
@@ -148,6 +148,7 @@ function ResearchModeCard() {
|
||||
title={t("settings.researchMode.sectionTitle")}
|
||||
description={t("settings.researchMode.subtitle")}
|
||||
/>
|
||||
<p className="text-sm">{t("settings.researchMode.subtitleDetail")}</p>
|
||||
|
||||
{showRePrompt && (
|
||||
<SettingsInfoTile
|
||||
@@ -262,6 +263,7 @@ function DataResetCard() {
|
||||
title={t("settings.dangerZone")}
|
||||
description={t("settings.dangerZoneDescription")}
|
||||
/>
|
||||
<p className="text-sm">{t("settings.dangerZoneDetail")}</p>
|
||||
|
||||
{msg && (
|
||||
<p
|
||||
@@ -399,6 +401,7 @@ function AccountDeleteCard() {
|
||||
title={t("settings.deleteAccountCardTitle")}
|
||||
description={t("settings.deleteAccountCardDescription")}
|
||||
/>
|
||||
<p className="text-sm">{t("settings.deleteAccountCardDetail")}</p>
|
||||
|
||||
{msg && (
|
||||
<p
|
||||
|
||||
@@ -213,9 +213,7 @@ function ApiTokensCard() {
|
||||
authenticated route accepted. The per-medication API-endpoint
|
||||
toggle issues the pair that actually works, scoped to one
|
||||
medication. This card lists and revokes. */}
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.tokenMintMovedDescription")}
|
||||
</p>
|
||||
<p className="text-sm">{t("settings.tokenMintMovedDescription")}</p>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium">
|
||||
|
||||
@@ -149,6 +149,7 @@ export function CoachMemorySection({
|
||||
title={t("settings.ai.coachMemory.title")}
|
||||
description={t("settings.ai.coachMemory.description")}
|
||||
/>
|
||||
<p className="text-sm">{t("settings.ai.coachMemory.detail")}</p>
|
||||
|
||||
{query.isError && (
|
||||
<p
|
||||
|
||||
@@ -162,6 +162,7 @@ export function CoachRemindersSection({
|
||||
title={t("settings.ai.coachReminders.title")}
|
||||
description={t("settings.ai.coachReminders.description")}
|
||||
/>
|
||||
<p className="text-sm">{t("settings.ai.coachReminders.detail")}</p>
|
||||
|
||||
{query.isError && (
|
||||
<p
|
||||
|
||||
@@ -66,6 +66,8 @@ export function CycleTrackingCard({
|
||||
title={t("settings.cycleTracking.title")}
|
||||
description={t("settings.cycleTracking.description")}
|
||||
/>
|
||||
<p className="text-sm">{t("settings.cycleTracking.detail")}</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<Label htmlFor="cycle-tracking-enable" className="text-sm">
|
||||
|
||||
@@ -265,6 +265,7 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
|
||||
title={t("insights.pillOrder.title")}
|
||||
description={t("insights.pillOrder.description")}
|
||||
/>
|
||||
<p className="text-sm">{t("insights.pillOrder.detail")}</p>
|
||||
|
||||
{isLoading ? (
|
||||
<p
|
||||
|
||||
@@ -61,7 +61,7 @@ describe("<AppleHealthCard>", () => {
|
||||
|
||||
expect(html).toContain('data-testid="apple-health-card"');
|
||||
expect(html).toContain("HealthLog iOS app");
|
||||
expect(html).toContain("not a web or OAuth connection");
|
||||
expect(html).toContain("not from a web or OAuth connection");
|
||||
expect(html).toContain("Settings → Apple Health");
|
||||
expect(html).toContain("when iOS grants background time");
|
||||
expect(html).not.toContain(">Connected<");
|
||||
|
||||
@@ -234,6 +234,8 @@ export function LowStockCard({
|
||||
disabled={!isAuthenticated || saving}
|
||||
aria-label={t("notifications.lowStock.toggleAria")}
|
||||
/>
|
||||
<p className="text-sm">{t("notifications.lowStock.detail")}</p>
|
||||
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{enabled
|
||||
? t("notifications.lowStock.statusOn")
|
||||
|
||||
@@ -91,6 +91,9 @@ export function ManagedIntegrationStatus() {
|
||||
title={t("settings.sections.integrations.title")}
|
||||
description={t("settings.sharedRecord.integrationStatusDescription")}
|
||||
/>
|
||||
<p className="text-sm">
|
||||
{t("settings.sharedRecord.integrationStatusDetail")}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ function McpConnectionsCard() {
|
||||
title={t("settings.mcp.connectionsTitle")}
|
||||
description={t("settings.mcp.connectionsDescription")}
|
||||
/>
|
||||
<p className="text-sm">{t("settings.mcp.connectionsDetail")}</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{list.length === 0 ? (
|
||||
@@ -336,6 +337,7 @@ function McpTokensCard() {
|
||||
title={t("settings.mcp.tokensTitle")}
|
||||
description={t("settings.mcp.tokensDescription")}
|
||||
/>
|
||||
<p className="text-sm">{t("settings.mcp.tokensDetail")}</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
|
||||
@@ -193,6 +193,8 @@ export function MoodReminderCard({
|
||||
disabled={!isAuthenticated || saving}
|
||||
aria-label={t("notifications.moodReminder.toggleAria")}
|
||||
/>
|
||||
<p className="text-sm">{t("notifications.moodReminder.detail")}</p>
|
||||
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{enabled
|
||||
? t("notifications.moodReminder.statusOn")
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
@@ -148,6 +149,10 @@ export function NotificationStatusCard() {
|
||||
<SettingsCardHeader
|
||||
icon={Bell}
|
||||
title={t("settings.notificationStatus.title")}
|
||||
/>
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title={t("settings.notificationStatus.emptyTitle")}
|
||||
description={t("settings.notificationStatus.emptyDescription")}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -78,6 +78,9 @@ export function NutrientIntakeCard() {
|
||||
days: WINDOW_DAYS,
|
||||
})}
|
||||
/>
|
||||
<p className="text-sm">
|
||||
{t("settings.sections.sources.nutrients.detail")}
|
||||
</p>
|
||||
{overview.isLoading ? (
|
||||
<div className="space-y-1" aria-hidden="true">
|
||||
<Skeleton className="h-9 w-full rounded-md" />
|
||||
|
||||
@@ -16,7 +16,6 @@ import Link from "next/link";
|
||||
import {
|
||||
Database,
|
||||
Download,
|
||||
Lock,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
Clock,
|
||||
@@ -67,21 +66,11 @@ export function PrivacySection() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Intro */}
|
||||
<SettingsInfoTile
|
||||
icon={Lock}
|
||||
tone="info"
|
||||
title={t("settings.privacy.intro.title")}
|
||||
>
|
||||
{t("settings.privacy.intro.body")}
|
||||
</SettingsInfoTile>
|
||||
|
||||
{/* Encryption at rest */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={ShieldCheck}
|
||||
title={t("settings.privacy.encryption.title")}
|
||||
description={t("settings.privacy.encryption.description")}
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm">
|
||||
@@ -101,7 +90,6 @@ export function PrivacySection() {
|
||||
<SettingsCardHeader
|
||||
icon={Database}
|
||||
title={t("settings.privacy.stored.title")}
|
||||
description={t("settings.privacy.stored.description")}
|
||||
/>
|
||||
<ul className="text-muted-foreground list-disc space-y-1 text-sm">
|
||||
<li>{t("settings.privacy.stored.measurements")}</li>
|
||||
|
||||
@@ -138,6 +138,7 @@ function ShareLinksCard() {
|
||||
title={t("settings.sharing.createTitle")}
|
||||
description={t("settings.sharing.createDescription")}
|
||||
/>
|
||||
<p className="text-sm">{t("settings.sharing.createDetail")}</p>
|
||||
|
||||
<ShareLinkCreateForm
|
||||
key={prefill?.key ?? "new"}
|
||||
|
||||
@@ -315,6 +315,8 @@ export function SourcesSection() {
|
||||
}
|
||||
/>
|
||||
|
||||
<p className="text-sm">{t("settings.sections.sources.helpDetail")}</p>
|
||||
|
||||
{isLoading || !priority ? (
|
||||
<SourcesSkeletonList />
|
||||
) : (
|
||||
|
||||
Reference in new issue
Block a user