mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
2f7f04ecab
commit
6e54f33901
32 files changed
+291
-119
No files matched your search
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Whether the catalogue has a sentence is asked in one place.
|
||||
*
|
||||
* `t()` answers a missing key with the key itself, so "does this code have a
|
||||
* sentence" is a comparison of the answer with the question. Written inline it
|
||||
* was copied once per surface that wanted a code's sentence or else a
|
||||
* fallback. It lives in `src/lib/i18n/translate-code.ts` now
|
||||
* (`translateOrNull`, `translateCode`), and this guard refuses the comparison
|
||||
* anywhere else under `src/`.
|
||||
*
|
||||
* Two shapes are read: a translation held in a variable and compared with the
|
||||
* key it was asked for (`const v = t(key); v === key`), and the inline form
|
||||
* (`t(k) !== k`). The helper itself must match, so a matcher that stops
|
||||
* finding the shape fails instead of passing over nothing.
|
||||
*/
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const SRC = join(__dirname, "..");
|
||||
const HELPER = "lib/i18n/translate-code.ts";
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === "__tests__" || entry.name === "generated") continue;
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(path, out);
|
||||
else if (/\.tsx?$/.test(entry.name) && !entry.name.includes(".test."))
|
||||
out.push(path);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function escape(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/** The probes in one source text, one line each. */
|
||||
function probes(source: string): string[] {
|
||||
const found: string[] = [];
|
||||
// Inline: `t(K) !== K` / `t(K, p) === K`, K an identifier or a template.
|
||||
const inline =
|
||||
/\bt\(\s*(`[^`]*`|[A-Za-z_$][\w$]*)\s*(?:,[^()]*)?\)\s*[!=]==\s*(`[^`]*`|[A-Za-z_$][\w$]*)/g;
|
||||
for (const m of source.matchAll(inline)) {
|
||||
if (m[1] === m[2]) found.push(m[0]);
|
||||
}
|
||||
// Held: `const v = t(K…)` (or `K ? t(K) : null`) and later `v === K` or
|
||||
// `K !== v`.
|
||||
const held =
|
||||
/\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:[\w$.]+\s*\?\s*)?t\(\s*([A-Za-z_$][\w$]*)\b/g;
|
||||
for (const m of source.matchAll(held)) {
|
||||
const [, value, key] = m;
|
||||
const compare = new RegExp(
|
||||
`\\b${escape(value)}\\s*[!=]==\\s*${escape(key)}\\b|\\b${escape(key)}\\s*[!=]==\\s*${escape(value)}\\b`,
|
||||
);
|
||||
if (compare.test(source.slice(m.index))) found.push(m[0]);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
const files = walk(SRC).map((file) => ({
|
||||
file: relative(SRC, file),
|
||||
source: readFileSync(file, "utf8"),
|
||||
}));
|
||||
|
||||
describe("the catalogue probe lives in one helper", () => {
|
||||
it("recognises both shapes", () => {
|
||||
expect(probes("const s = t(key);\nreturn s === key ? a : s;")).toHaveLength(
|
||||
1,
|
||||
);
|
||||
expect(probes("if (t(`x.${c}`) !== `x.${c}`) go();")).toHaveLength(1);
|
||||
expect(
|
||||
probes("const label = t(key, params); if (key !== label) {}"),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
probes("const v = key ? t(key) : null; return v && v !== key ? v : f;"),
|
||||
).toHaveLength(1);
|
||||
expect(probes("const s = t(key); return s;")).toEqual([]);
|
||||
expect(probes("if (row.key === key) {}")).toEqual([]);
|
||||
});
|
||||
|
||||
it("finds the probe in the helper", () => {
|
||||
const helper = files.find(({ file }) => file === HELPER);
|
||||
expect(helper).toBeDefined();
|
||||
expect(probes(helper!.source).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("finds it nowhere else", () => {
|
||||
const offenders = files
|
||||
.filter(({ file }) => file !== HELPER)
|
||||
.flatMap(({ file, source }) =>
|
||||
probes(source).map((p) => `${file}: ${p}`),
|
||||
);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { canLogWorkout } from "@/lib/workouts/manual-entry";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { translateOrNull } from "@/lib/i18n/translate-code";
|
||||
|
||||
/**
|
||||
* v1.4.32 — `/insights/workouts`.
|
||||
@@ -82,9 +83,7 @@ export default function InsightsWorkoutsPageClient() {
|
||||
const sportLabel = (sport: string) => {
|
||||
// A sport enum the bundle has no copy for renders as its canonical
|
||||
// string rather than a raw key — same fallback the list rows use.
|
||||
const key = `insights.workouts.sport.${sport}`;
|
||||
const label = t(key);
|
||||
return label === key ? sport : label;
|
||||
return translateOrNull(t, `insights.workouts.sport.${sport}`) ?? sport;
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -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})`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"]) {
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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). */
|
||||
|
||||
Reference in new issue
Block a user