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,6 +2,7 @@ import {
CHANNEL_TYPE_LABELS,
type ChannelType,
} from "@/lib/notifications/types";
import { translateCodeOrNull } from "@/lib/i18n/translate-code";
/** One channel's row in the admin notification test response. */
export interface ChannelTestResult {
@@ -27,14 +28,10 @@ export function describeChannelTestFailures(
.filter((r) => !r.success)
.map((r) => {
const label = CHANNEL_TYPE_LABELS[r.channel as ChannelType] ?? r.channel;
const key = r.errorCode
? `settings.testConnection.errors.${r.errorCode}`
: null;
const translated = key ? t(key) : null;
const cause =
translated && translated !== key
? translated
: (r.error ?? t("admin.notificationTestFailed"));
translateCodeOrNull(t, "settings.testConnection.errors", r.errorCode) ??
r.error ??
t("admin.notificationTestFailed");
const code =
typeof r.upstreamStatus === "number"
? ` (HTTP ${r.upstreamStatus})`
+2 -2
View File
@@ -89,6 +89,7 @@ import {
DEFAULT_CHART_RANGE,
rangeWindowDays,
} from "./chart-range-tabs";
import { translateOrNull } from "@/lib/i18n/translate-code";
// The range tabs (7 / 30 / 90 / All, a calendar-day window ending now) and
// the "All" tab's ten-year fetch span live with the shared
@@ -399,8 +400,7 @@ function getTypeLabel(
const key = BASE_TYPE_LABEL_KEYS[type];
if (key) return t(key);
const measurementKey = measurementLabelKey(type);
const label = t(measurementKey);
return label === measurementKey ? type : label;
return translateOrNull(t, measurementKey) ?? type;
}
/**
@@ -12,6 +12,7 @@ import { TileHeader } from "@/components/insights/tile-header";
import { Skeleton } from "@/components/ui/skeleton";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { iconForSport } from "@/lib/workouts/sport-icons";
import { translateOrNull } from "@/lib/i18n/translate-code";
/**
* v1.4.32 — `<RecentWorkoutsTile>`.
@@ -122,9 +123,8 @@ export function RecentWorkoutsTile() {
<ul className="space-y-2">
{workouts.map((workout) => {
const sportLabelKey = `insights.workouts.sport.${workout.sportType}`;
const sportLabel = t(sportLabelKey);
const sportName =
sportLabel === sportLabelKey ? workout.sportType : sportLabel;
translateOrNull(t, sportLabelKey) ?? workout.sportType;
return (
<li key={workout.id}>
<Link
@@ -21,6 +21,7 @@ import type { CoachAssumption, CoachFollowUp } from "@/lib/ai/coach/types";
import { SuggestedReplies } from "./suggested-replies";
import { COACH_FOCUS_RING } from "./focus-ring";
import { translateOrNull } from "@/lib/i18n/translate-code";
/** The alternatives the server offers for one assumption. */
export function alternativesFor(
@@ -39,8 +40,7 @@ function optionLabel(
t: (key: string) => string,
option: CoachAssumption["value"],
): string {
const local = t(option.labelKey);
return local === option.labelKey ? option.label : local;
return translateOrNull(t, option.labelKey) ?? option.label;
}
export interface CoachAssumptionLineProps {
@@ -38,6 +38,7 @@ import type {
CoachMemoryNote as CoachMemoryNoteFrame,
CoachProvenanceMetric,
} from "@/lib/ai/coach/types";
import { translateOrNull } from "@/lib/i18n/translate-code";
/**
* Per-message bubble renderer for the Coach thread, split out of
@@ -239,8 +240,8 @@ function coachChartTitle(
): string {
const override = CHART_TITLE_KEY_OVERRIDE[metric];
if (override) {
const resolved = t(override);
if (resolved !== override) return resolved;
const resolved = translateOrNull(t, override);
if (resolved !== null) return resolved;
}
const camel = metric
.toLowerCase()
@@ -250,8 +251,7 @@ function coachChartTitle(
)
.join("");
const key = `measurements.type${camel.charAt(0).toUpperCase()}${camel.slice(1)}`;
const resolved = t(key);
return resolved === key ? metric.replace(/_/g, " ").toLowerCase() : resolved;
return translateOrNull(t, key) ?? metric.replace(/_/g, " ").toLowerCase();
}
interface ChatBubbleProps {
@@ -564,16 +564,11 @@ function ChatBubbleImpl({
// provider load (retry in ~5 min). Codes that have no dedicated
// translation fall back to the generic provider-unavailable copy.
const errorKey = errorCode ? errorCodeToI18nKey(errorCode) : null;
const errorMessage = errorKey ? t(errorKey, {}) : null;
// When a translated message comes back unchanged (i.e. key missing)
// we fall back to a generic provider error string so the bubble
// doesn't surface raw `coach.http.503` text to the user.
// A code without a sentence falls back to the generic provider error, so
// the bubble never surfaces raw `coach.http.503` text to the user.
const safeError =
errorMessage && errorMessage !== errorKey
? errorMessage
: errorCode
? t("insights.coach.errorProvider")
: null;
(errorKey ? translateOrNull(t, errorKey) : null) ??
(errorCode ? t("insights.coach.errorProvider") : null);
const method = metricSource?.method ?? null;
@@ -67,6 +67,7 @@ import {
useDetachCoachDocument,
useSendCoachMessage,
} from "./use-coach";
import { translateOrNull } from "@/lib/i18n/translate-code";
/**
* v1.12.0 (Coach v2 #6) — shared chat surface for both the Coach drawer
@@ -987,11 +988,8 @@ export function CoachConversation({
// from a metric surface names the metric there until its first turn.
const a2Metric = launchScope?.metric ?? null;
// `t()` returns the key string itself when a key is missing, so the only
// reliable "is this key defined" signal is `t(key) !== key`.
function tOrNull(key: string): string | null {
const resolved = t(key);
return resolved === key ? null : resolved;
return translateOrNull(t, key);
}
// The resolved metric label: prefers a per-source i18n key, falls back to
@@ -42,6 +42,7 @@ import {
COACH_MEMORY_KEYS,
COACH_PLAN_KEYS,
} from "@/lib/ai/coach/memory/shared";
import { translateOrNull } from "@/lib/i18n/translate-code";
// v1.28.26 file-size split (pure code motion): the bubble renderer +
// per-message actions live in `chat-bubble.tsx`, the read-aloud stack in
@@ -215,8 +216,7 @@ export function decisionReplies(
]
: choices;
for (const choice of ordered.slice(0, 4)) {
const local = t(choice.labelKey);
const label = local === choice.labelKey ? choice.label : local;
const label = translateOrNull(t, choice.labelKey) ?? choice.label;
out.push({
id: `clarify-${choice.id}`,
label,
@@ -82,6 +82,7 @@ import type {
import { COACH_FOCUS_RING } from "./focus-ring";
import { CoachMethodLine } from "./method-line";
import { translateOrNull } from "@/lib/i18n/translate-code";
/** The shortest gap between two screen-reader announcements. */
export const ANNOUNCE_THROTTLE_MS = 1500;
@@ -106,8 +107,7 @@ function tOr(
fallback: string,
params?: Record<string, string | number>,
) {
const value = t(key, params);
return value === key ? fallback : value;
return translateOrNull(t, key, params) ?? fallback;
}
// ── Steps (messages saved before v1.41, and the fetch rows) ────────────────
@@ -180,9 +180,8 @@ export function legacyAreaLabels(
const out: string[] = [];
for (const area of areas ?? []) {
if (area === "general") continue;
const key = `insights.coach.metric.${area}`;
const label = t(key);
if (label === key || out.includes(label)) continue;
const label = translateOrNull(t, `insights.coach.metric.${area}`);
if (label === null || out.includes(label)) continue;
out.push(label);
}
return out;
@@ -7,6 +7,7 @@ import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { formatDuration, formatDistance } from "./format";
import { translateOrNull } from "@/lib/i18n/translate-code";
/**
* Hero header — the at-a-glance verdict row. Sport icon badge, source
@@ -34,9 +35,7 @@ export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
const fmt = useFormatters();
const { preference } = useUnitDisplay();
const sportLabelKey = `insights.workouts.sport.${workout.sportType}`;
const sportLabel = t(sportLabelKey);
const sportName =
sportLabel === sportLabelKey ? workout.sportType : sportLabel;
const sportName = translateOrNull(t, sportLabelKey) ?? workout.sportType;
return (
<header
+2 -2
View File
@@ -13,6 +13,7 @@ import type { WorkoutListEntry } from "@/hooks/use-workouts";
import { Button } from "@/components/ui/button";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { formatDistance } from "@/components/insights/workout-detail/format";
import { translateOrNull } from "@/lib/i18n/translate-code";
/**
* v1.4.32 — workout list row primitive.
@@ -63,12 +64,11 @@ export function WorkoutList({
{workouts.map((workout) => {
const Icon = iconForSport(workout.sportType);
const sportLabelKey = `insights.workouts.sport.${workout.sportType}`;
const sportLabel = t(sportLabelKey);
// Translation keys missing from one locale fall back to the
// canonical sport-type string so a new HK enum value never
// renders the raw `insights.workouts.sport.x` placeholder.
const sportName =
sportLabel === sportLabelKey ? workout.sportType : sportLabel;
translateOrNull(t, sportLabelKey) ?? workout.sportType;
return (
// v1.42 — the row opens the workout and its date opens the day:
+3 -3
View File
@@ -63,6 +63,7 @@ import type { EncounterDTO } from "@/lib/encounters/dto";
import type { IllnessEpisodeDTO } from "@/lib/illness/dto";
import type { RelatedLink } from "@/lib/links/related";
import type { EncounterKind } from "@/generated/prisma/client";
import { translateOrNull } from "@/lib/i18n/translate-code";
type Translate = ReturnType<typeof useTranslations>["t"];
type RelatedKind = RelatedLink["kind"];
@@ -114,9 +115,8 @@ function rowLabel(t: Translate, row: RelatedLink): string {
// A dose without a written name arrives as its catalogue slug ("tetanus"),
// named here from the reader's bundle the way the dose picker names it.
if (row.kind === "vaccination" && /^[a-z0-9_-]+$/.test(row.label)) {
const key = `vaccinations.catalog.${row.label}`;
const name = t(key);
if (name !== key) return name;
const name = translateOrNull(t, `vaccinations.catalog.${row.label}`);
if (name !== null) return name;
}
return row.label;
}
@@ -44,6 +44,7 @@ import {
useMeasurementReminderMutations,
type MeasurementReminder,
} from "@/hooks/use-measurement-reminders";
import { translateOrNull } from "@/lib/i18n/translate-code";
const SUMMARY_LIMIT = 3;
@@ -61,8 +62,7 @@ function resolveLabel(
t: (key: string) => string,
): string {
if (reminder.origin === "COACH") {
const translated = t(reminder.label);
return translated === reminder.label ? reminder.label : translated;
return translateOrNull(t, reminder.label) ?? reminder.label;
}
return reminder.label;
}
@@ -103,6 +103,7 @@ import {
shiftDateKey,
userDayKey,
} from "@/lib/tz/format";
import { translateOrNull } from "@/lib/i18n/translate-code";
// v1.18.1 — the active-measurement set, grouped so the dropdown
// doesn't read as a flat 15-item list. Mirrors the Zod allow-list in
@@ -169,10 +170,9 @@ function resolveReminderLabel(
t: (key: string) => string,
): string {
if (reminder.origin === "COACH") {
const translated = t(reminder.label);
// `t()` echoes the key back on a miss; fall back to the raw value so a
// future catalog addition never surfaces a bare key.
return translated === reminder.label ? reminder.label : translated;
// A label the catalogue has no sentence for shows as written, so a future
// catalog addition never surfaces a bare key.
return translateOrNull(t, reminder.label) ?? reminder.label;
}
return reminder.label;
}
@@ -29,14 +29,14 @@ import {
useMeasurementReminderMutations,
type MeasurementReminder,
} from "@/hooks/use-measurement-reminders";
import { translateOrNull } from "@/lib/i18n/translate-code";
function resolveLabel(
reminder: MeasurementReminder,
t: (key: string) => string,
): string {
if (reminder.origin === "COACH") {
const translated = t(reminder.label);
return translated === reminder.label ? reminder.label : translated;
return translateOrNull(t, reminder.label) ?? reminder.label;
}
return reminder.label;
}
@@ -5,6 +5,7 @@ import {
type RejectedProfileField,
} from "@/lib/profile/rejected-fields";
import { toProfileSex } from "@/lib/profile/sex";
import { translateCode } from "@/lib/i18n/translate-code";
/**
* Reading the answer to the onboarding baseline step's profile write.
@@ -181,10 +182,5 @@ function localizedCode(
errorCode: string | undefined,
t: (key: string, params?: Record<string, string | number>) => string,
): string {
if (errorCode) {
const key = `apiErrors.${errorCode}`;
const translated = t(key);
if (translated !== key) return translated;
}
return t("onboarding.errorGeneric");
return translateCode(t, "apiErrors", errorCode, "onboarding.errorGeneric");
}
@@ -58,6 +58,7 @@ import {
import { AvatarSection } from "./avatar-section";
import { SetupCard } from "./setup-card";
import { dateOnlyKey } from "@/lib/tz/date-only";
import { translateCodeOrNull } from "@/lib/i18n/translate-code";
export { resolveInitialTimezone } from "./account-section-utils";
@@ -368,10 +369,7 @@ export function AccountSection() {
setFieldErrors(describeRejectedProfileFields(json?.details?.issues, t));
if (errorCode === "profile.update.nothingSaved" && field) {
setSaveMsg({ key: "settings.profileNothingSaved", params: { field } });
} else if (
errorCode &&
t(`apiErrors.${errorCode}`) !== `apiErrors.${errorCode}`
) {
} else if (translateCodeOrNull(t, "apiErrors", errorCode) !== null) {
// Only a code with a sentence of its own: every refusal carries a
// code now, and the generic `http.*` family has no translation.
setSaveMsg({ key: `apiErrors.${errorCode}` });
@@ -30,6 +30,7 @@ import {
useSyncOutcomeMessage,
type SyncOutcomeState,
} from "./sync-outcome";
import { translateCode } from "@/lib/i18n/translate-code";
export interface ProviderSync {
syncing: boolean;
@@ -151,11 +152,12 @@ export function ActionResultLines({
const { t } = useTranslations();
function describeError(errorCode: string): string {
const key = `settings.testConnection.errors.${errorCode}`;
const translated = t(key);
return translated === key
? t("settings.testConnection.errors.generic")
: translated;
return translateCode(
t,
"settings.testConnection.errors",
errorCode,
"settings.testConnection.errors.generic",
);
}
const busy = sync?.syncing || test?.testing;
+7 -6
View File
@@ -22,6 +22,7 @@ import {
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { translateCodeOrNull } from "@/lib/i18n/translate-code";
interface NtfySettings {
enabled: boolean;
@@ -80,13 +81,13 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
};
// A coded refusal (the private-origin policy) translates like the
// test button's; anything else shows the server sentence.
const code = json.meta?.errorCode;
const key = code ? `settings.testConnection.errors.${code}` : null;
const translated = key ? t(key) : null;
throw new Error(
translated && translated !== key
? translated
: json.error || t("common.error"),
translateCodeOrNull(
t,
"settings.testConnection.errors",
json.meta?.errorCode,
) ??
(json.error || t("common.error")),
);
}
},
@@ -6,6 +6,7 @@ import { CheckCircle2, Loader2, Plug, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { translateCode } from "@/lib/i18n/translate-code";
/**
* Shared "Test connection" UX for the settings integrations + notifications
@@ -151,13 +152,12 @@ export function TestConnectionButton({
}
function describeError(errorCode: string): string {
const key = `settings.testConnection.errors.${errorCode}`;
const translated = t(key);
// `t()` returns the raw key when missing — fall back to generic.
if (translated === key) {
return t("settings.testConnection.errors.generic");
}
return translated;
return translateCode(
t,
"settings.testConnection.errors",
errorCode,
"settings.testConnection.errors.generic",
);
}
return (
+7 -6
View File
@@ -24,6 +24,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import type { WebhookPayloadFormat } from "@/lib/notifications/types";
import { translateCodeOrNull } from "@/lib/i18n/translate-code";
interface WebhookSettings {
enabled: boolean;
@@ -89,13 +90,13 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
};
// A coded refusal (the private-origin policy) translates like the
// test button's; anything else shows the server sentence.
const code = json.meta?.errorCode;
const key = code ? `settings.testConnection.errors.${code}` : null;
const translated = key ? t(key) : null;
throw new Error(
translated && translated !== key
? translated
: json.error || t("common.error"),
translateCodeOrNull(
t,
"settings.testConnection.errors",
json.meta?.errorCode,
) ??
(json.error || t("common.error")),
);
}
},
@@ -24,8 +24,12 @@ import {
const ROOTS = ["src/components/timeline", "src/components/day"] as const;
/** `t(` or `tCount(` whose key is a template literal, across line breaks. */
const TEMPLATE_KEY = /(?<![\w.])(?:t|tCount)\(\s*`/g;
/**
* `t(`, `tCount(` or the catalogue probe `translateOrNull(t, ` whose key is
* a template literal, across line breaks.
*/
const TEMPLATE_KEY =
/(?<![\w.])(?:t|tCount)\(\s*`|(?<![\w.])translateOrNull\(\s*t\s*,\s*`/g;
const ALLOWED: Readonly<Record<string, { count: number; why: string }>> = {
"src/components/timeline/readiness-inventory.tsx": {
@@ -34,6 +34,7 @@ import {
} from "./label-keys";
import { formatMonthYear } from "./timeline-dates";
import { orderedReadinessLanes, readinessTally } from "./readiness-model";
import { translateOrNull } from "@/lib/i18n/translate-code";
type Translate = ReturnType<typeof useTranslations>["t"];
type TranslateCount = ReturnType<typeof useTranslations>["tCount"];
@@ -65,11 +66,10 @@ export function wordKey(
params: Record<string, string | number>,
): string | null {
const count = typeof params.count === "number" ? params.count : null;
if (count !== null && t(`${base}Other`) !== `${base}Other`) {
if (count !== null && translateOrNull(t, `${base}Other`) !== null) {
return tCount(base, count, params);
}
const single = t(base, params);
return single === base ? null : single;
return translateOrNull(t, base, params);
}
export function laneLabel(t: Translate, key: TimelineReadinessLane["key"]) {