mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +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
@@ -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