refactor(i18n): ask the catalogue for a sentence in one helper

`t()` answers a missing key with the key, so "does this code have a
sentence" was a comparison written inline at every site that wanted a
code's sentence or a fallback, twenty-odd times in several spellings.
translateOrNull, translateCodeOrNull and translateCode in
src/lib/i18n/translate-code.ts now hold that probe, every site uses
them, and a guard refuses the inline comparison anywhere else.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 09:53:31 +02:00
1 parent 2f7f04ecab
commit 6e54f33901
32 files changed
+291 -119

No files matched your search

+2 -2
View File
@@ -27,6 +27,7 @@ import {
coachDomainLabelKey,
coachPeriodLabelKey,
} from "@/lib/ai/coach/dialog-keys";
import { translateOrNull } from "@/lib/i18n/translate-code";
export interface ProjectionContext {
ref: string;
@@ -58,9 +59,8 @@ export function projectWorkouts(
for (const entry of perSport) {
if (!isRecord(entry) || typeof entry.sport !== "string") continue;
const labelKey = `insights.workouts.sport.${entry.sport}`;
const label = t(labelKey);
rows.push([
label === labelKey ? entry.sport : label,
translateOrNull(t, labelKey) ?? entry.sport,
numberOrNull(entry.count),
numberOrNull(entry.totalDurationMin),
]);
+6 -10
View File
@@ -1,4 +1,5 @@
import { ApiError } from "./api-fetch";
import { translateCode } from "@/lib/i18n/translate-code";
type Translate = (key: string) => string;
@@ -12,14 +13,9 @@ export function localizedApiError(
t: Translate,
fallbackKey: string,
): string {
if (error instanceof ApiError) {
const errorCode = error.meta?.errorCode;
if (typeof errorCode === "string") {
const key = `apiErrors.${errorCode}`;
const translated = t(key);
if (translated !== key) return translated;
}
}
return t(fallbackKey);
const errorCode =
error instanceof ApiError && typeof error.meta?.errorCode === "string"
? error.meta.errorCode
: null;
return translateCode(t, "apiErrors", errorCode, fallbackKey);
}
+2 -2
View File
@@ -98,6 +98,7 @@ import type {
TodaySleep,
TodayVital,
} from "@/lib/daily/today-overview";
import { translateOrNull } from "@/lib/i18n/translate-code";
/** Integration states that mean "your action is needed to keep data flowing". */
const SYNC_ISSUE_STATES = ["error_reauth", "parked"] as const;
@@ -907,8 +908,7 @@ export async function loadDailyDigest(
if (!staysDue && row.nextDueAt.getTime() > now.getTime()) continue;
let label = row.label;
if (row.origin === "COACH") {
const translated = t(row.label);
if (translated !== row.label) label = translated;
label = translateOrNull(t, row.label) ?? label;
}
preventiveDue.push({ label, staysDue });
}
+2 -2
View File
@@ -27,6 +27,7 @@ import { formatDurationMinutes } from "@/lib/i18n/duration";
import { isFromToday } from "@/lib/insights/measurement-freshness";
import type { ModuleKey } from "@/lib/modules/registry";
import type { PriorityItem } from "@/lib/daily/priority-item";
import { translateOrNull } from "@/lib/i18n/translate-code";
type Translate = (
key: string,
@@ -510,8 +511,7 @@ function outsideBy(v: TodayVital): number {
function vitalLabel(t: Translate, type: string): string {
const key = `daily.today.vital.${type}`;
const label = t(key);
return label === key ? t("daily.today.vital.generic") : label;
return translateOrNull(t, key) ?? t("daily.today.vital.generic");
}
function sleepDiff(sleep: TodaySleep): number | null {
+3 -3
View File
@@ -145,6 +145,7 @@ import {
hideModuleWidgets,
unavailableWidgetIds,
} from "@/lib/dashboard/widget-modules";
import { translateOrNull } from "@/lib/i18n/translate-code";
/**
* v1.7.0 — HealthLog `MeasurementType` → iOS `MetricKind` raw value.
@@ -1217,9 +1218,8 @@ function localisedMeasurementLabel(
)
.join("");
const key = `measurements.type${camel.charAt(0).toUpperCase()}${camel.slice(1)}`;
const resolved = t(key);
// The server translator echoes the key on a miss; fall back to a readable form.
return resolved === key ? type.replace(/_/g, " ").toLowerCase() : resolved;
// A type without a sentence falls back to a readable form.
return translateOrNull(t, key) ?? type.replace(/_/g, " ").toLowerCase();
}
/**
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import {
translateCode,
translateCodeOrNull,
translateOrNull,
} from "../translate-code";
const CATALOGUE: Record<string, string> = {
"apiErrors.sharing.invite.self": "That is your own record.",
"common.error": "Something went wrong.",
"greeting.hello": "Hello {name}",
};
// Echoes the key on a miss, the way the real translator does.
function t(key: string, params?: Record<string, string | number>): string {
const value = CATALOGUE[key];
if (value === undefined) return key;
return params ? value.replace("{name}", String(params.name)) : value;
}
describe("translate-code", () => {
it("answers null for a key the catalogue does not carry", () => {
expect(translateOrNull(t, "missing.key")).toBeNull();
expect(translateOrNull(t, "greeting.hello", { name: "A" })).toBe("Hello A");
});
it("translates a code under its namespace, or answers null", () => {
expect(translateCodeOrNull(t, "apiErrors", "sharing.invite.self")).toBe(
"That is your own record.",
);
expect(translateCodeOrNull(t, "apiErrors", "http.not_found")).toBeNull();
expect(translateCodeOrNull(t, "apiErrors", undefined)).toBeNull();
expect(translateCodeOrNull(t, "apiErrors", "")).toBeNull();
});
it("falls back to the fallback key's sentence", () => {
expect(
translateCode(t, "apiErrors", "http.not_found", "common.error"),
).toBe("Something went wrong.");
expect(translateCode(t, "apiErrors", null, "common.error")).toBe(
"Something went wrong.",
);
});
});
+47
View File
@@ -0,0 +1,47 @@
/**
* Asking the catalogue whether it has a sentence.
*
* `t()` answers a missing key with the key itself, so the only way to learn
* whether a key exists is to compare the answer with the question. That probe
* lives here and nowhere else: it had been written inline at each call site
* that wanted "a code's sentence, or else the fallback", once per site.
* `src/__tests__/translate-probe-guard.test.ts` refuses it outside this file.
*/
type Translate = (
key: string,
params?: Record<string, string | number>,
) => string;
/** The catalogue's sentence for `key`, or null when the bundle has none. */
export function translateOrNull(
t: Translate,
key: string,
params?: Record<string, string | number>,
): string | null {
const value = t(key, params);
return value === key ? null : value;
}
/**
* The sentence for a machine code under `namespace` (`apiErrors`,
* `settings.testConnection.errors`, …), or null when there is no code or the
* code has no sentence of its own.
*/
export function translateCodeOrNull(
t: Translate,
namespace: string,
code: string | null | undefined,
): string | null {
return code ? translateOrNull(t, `${namespace}.${code}`) : null;
}
/** The code's sentence, or the fallback key's when it has none. */
export function translateCode(
t: Translate,
namespace: string,
code: string | null | undefined,
fallbackKey: string,
): string {
return translateCodeOrNull(t, namespace, code) ?? t(fallbackKey);
}
+4 -5
View File
@@ -55,6 +55,7 @@ import {
import { type Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { shiftDateKey } from "@/lib/tz/format";
import { translateOrNull } from "@/lib/i18n/translate-code";
/**
* Locale-aware string builder — the same signature the server translator
@@ -417,11 +418,9 @@ function humanise(key: string, t: Translate): string {
return t("insights.correlation.channelFactor", { factor });
}
const labelKey = `insights.correlation.channel.${key}`;
const label = t(labelKey);
// The server translator echoes the key on a miss; fall back to a readable
// prettified form so an unmapped channel degrades gracefully, never English-
// leaks a raw key.
return label === labelKey ? key.replace(/_/g, " ").toLowerCase() : label;
// An unmapped channel falls back to a readable prettified form, never a
// raw key.
return translateOrNull(t, labelKey) ?? key.replace(/_/g, " ").toLowerCase();
}
/** Namespace prefix for a RATED-mood-factor discovery channel (v1.14.0). */