feat: add i18n with LingUI, Crowdin integration, and typed error codes (#16)

* feat: add i18n with LingUI, Crowdin integration, and typed error codes

- Add `@sofa/i18n` shared package with LingUI v5 (v6-ready config using
  `defineConfig` + `@lingui/format-po`), eager English + lazy-loaded
  fr/de/es/it/pt catalogs, `Intl`-based date/number format utilities,
  and test helpers
- Wire `@lingui/vite-plugin` + babel macro plugin for web, and
  `@lingui/metro-transformer` + babel config for native
- Add `I18nProvider` to both app roots with locale auto-detection
  (navigator.language / expo-localization) and persistence
  (localStorage / MMKV)
- Wrap all ~512 user-facing strings across web and native with LingUI
  macros (`<Trans>`, `useLingui`/`t`, `i18n._(msg`...`)`, `plural`)
- Add language switcher to Settings in both apps
- Add `@sofa/api/errors` with 13 typed `AppErrorCode` values and
  `appErrorData()` helper for contract `.errors()` schemas
- Update all oRPC contract error definitions with typed `data` fields;
  convert import procedure `throw new Error()` to `ORPCError` with codes
- Add per-app `error-messages.ts` utilities that map error codes to
  localized strings; update global `QueryCache.onError` handlers to stop
  leaking raw `error.message` to users
- Add `crowdin.yml` config and `.github/workflows/crowdin.yml` for
  automated source upload on merge and translation pull via dispatch
- Add `lingui.config.ts` at repo root with `bun run i18n:extract` and
  `bun run i18n:compile` convenience scripts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: add Crowdin language mapping and ignore context file

* fix(i18n): address 20 localization quality issues

- Stop leaking raw error.message to users in change-password and
  account-section; use localized fallbacks instead
- Add typed error data to discover contract's .errors() schema
- Replace all manual English plural suffixes with LingUI plural() macro
  in episode counts, backup counts, star ratings, title/image counts
- Replace date-fns formatDistanceToNow and hardcoded date patterns with
  locale-aware formatDate/formatRelativeTime from @sofa/i18n/format
- Convert integration-configs from module-scope translations (frozen at
  import time) to lazy getIntegrationConfigs(i18n) function
- Combine split Trans fragments into single translatable units in
  backup-schedule retention sentence and stats-display period labels
- Localize TV media type badge and full episode accessibilityLabel
- Replace Android-incompatible Alert.alert language picker (max 3
  buttons) with zeego DropdownMenu
- Await async locale initialization before hiding splash screen so
  non-English users don't see English flash on cold start
- Add @lingui/core as direct native app dependency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(i18n): add Intl polyfills for Hermes, replace DropdownMenu language picker, and remove date-fns
- Add `@formatjs/intl-*` polyfills loaded at app startup in `intl-polyfills.ts` so Hermes on Android has full `Intl` support for locale-aware formatting
- Alias `@formatjs/icu-messageformat-parser` to its `no-parser` variant in metro config to reduce bundle size
- Replace the zeego `DropdownMenu` language picker in Settings with a new `SelectModal` component that works reliably on all platforms without the Android 3-button limit
- Replace `date-fns` `format`/`parseISO` in `person/[id].tsx` with `formatDate` from `@sofa/i18n/format`; remove `date-fns` from native and web `package.json`
- Refactor `StatusActionButton` to use a `StatusLabel` component with `<Trans>` rather than a `getStatusConfig(t)` factory so labels are always reactive to locale changes
- Fix crowdin workflow to use root `bun run i18n:compile` script instead of `cd packages/i18n && bun run compile`
- Wrap root layout `GestureHandlerRootView` in `SafeAreaProvider` and add `.catch` to locale-ready promise to prevent unhandled rejections blocking the splash screen

* fix(i18n): localize integration status helpers, fix stats-display Trans wrapping, and clean up SelectModal
- Localize `webhookStatus` and `listStatus` in `integration-card.tsx` using `i18n._(msg`...`)` so status strings are translated instead of always rendering in English
- Wrap "Movies {select}" and "Episodes {select}" in a single `<Trans>` in `stats-display.tsx` so the period selector element is embedded inside the translatable unit rather than concatenated outside it
- Fix locale activation order in Settings: close the modal first, then `activateLocale`, and only call `setPersistedLocale` on success to avoid persisting a locale that failed to load
- Remove the redundant `SafeAreaProvider`/`SafeAreaView` wrapper from `SelectModal` — the root layout already provides `SafeAreaProvider`
- Recompile all six `.po`/`.ts` catalogs to pick up new and updated message strings

* chore(i18n): crowdin sync

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 12:16:10 -04:00
committed by GitHub
co-authored by Claude Opus 4.6
parent e50ab44783
commit ad0497ccd6
142 changed files with 18002 additions and 1243 deletions
+1
View File
@@ -6,6 +6,7 @@
"exports": {
"./contract": "./src/contract.ts",
"./schemas": "./src/schemas.ts",
"./errors": "./src/errors.ts",
"./utils": "./src/utils.ts"
},
"scripts": {
+74 -15
View File
@@ -1,5 +1,6 @@
import { eventIterator, oc } from "@orpc/contract";
import { z } from "zod";
import { AppErrorCode, appErrorData } from "./errors";
import {
AuthConfigOutput,
BackupCreateOutput,
@@ -78,7 +79,10 @@ export const contract = {
.input(IdParam)
.output(TitleDetailOutput)
.errors({
NOT_FOUND: { message: "Title not found" },
NOT_FOUND: {
message: "Title not found",
data: appErrorData(AppErrorCode.TITLE_NOT_FOUND),
},
}),
updateStatus: oc
.route({
@@ -163,7 +167,10 @@ export const contract = {
.input(IdParam)
.output(QuickAddOutput)
.errors({
NOT_FOUND: { message: "Title not found" },
NOT_FOUND: {
message: "Title not found",
data: appErrorData(AppErrorCode.TITLE_NOT_FOUND),
},
}),
},
episodes: {
@@ -238,7 +245,10 @@ export const contract = {
.input(IdParam.merge(PaginatedInput))
.output(PersonDetailOutput)
.errors({
NOT_FOUND: { message: "Person not found" },
NOT_FOUND: {
message: "Person not found",
data: appErrorData(AppErrorCode.PERSON_NOT_FOUND),
},
}),
},
dashboard: {
@@ -315,7 +325,10 @@ export const contract = {
.input(TrendingTypeParam.merge(PageParam))
.output(TrendingOutput)
.errors({
PRECONDITION_FAILED: { message: "TMDB API key is not configured" },
PRECONDITION_FAILED: {
message: "TMDB API key is not configured",
data: appErrorData(AppErrorCode.TMDB_NOT_CONFIGURED),
},
}),
popular: oc
.route({
@@ -330,7 +343,10 @@ export const contract = {
.input(MediaTypeParam.merge(PageParam))
.output(PopularOutput)
.errors({
PRECONDITION_FAILED: { message: "TMDB API key is not configured" },
PRECONDITION_FAILED: {
message: "TMDB API key is not configured",
data: appErrorData(AppErrorCode.TMDB_NOT_CONFIGURED),
},
}),
genres: oc
.route({
@@ -345,7 +361,10 @@ export const contract = {
.input(MediaTypeParam)
.output(GenresOutput)
.errors({
PRECONDITION_FAILED: { message: "TMDB API key is not configured" },
PRECONDITION_FAILED: {
message: "TMDB API key is not configured",
data: appErrorData(AppErrorCode.TMDB_NOT_CONFIGURED),
},
}),
},
search: oc
@@ -376,7 +395,10 @@ export const contract = {
.input(DiscoverInput)
.output(DiscoverOutput)
.errors({
PRECONDITION_FAILED: { message: "TMDB API key is not configured" },
PRECONDITION_FAILED: {
message: "TMDB API key is not configured",
data: appErrorData(AppErrorCode.TMDB_NOT_CONFIGURED),
},
}),
system: {
publicInfo: oc
@@ -461,7 +483,10 @@ export const contract = {
.input(ProviderParam)
.output(IntegrationOutput)
.errors({
NOT_FOUND: { message: "Integration not found" },
NOT_FOUND: {
message: "Integration not found",
data: appErrorData(AppErrorCode.INTEGRATION_NOT_FOUND),
},
}),
},
admin: {
@@ -500,8 +525,14 @@ export const contract = {
.input(FilenameParam)
.output(z.void())
.errors({
NOT_FOUND: { message: "Backup not found" },
BAD_REQUEST: { message: "Failed to delete backup" },
NOT_FOUND: {
message: "Backup not found",
data: appErrorData(AppErrorCode.BACKUP_NOT_FOUND),
},
BAD_REQUEST: {
message: "Failed to delete backup",
data: appErrorData(AppErrorCode.BACKUP_DELETE_FAILED),
},
}),
restore: oc
.route({
@@ -515,7 +546,10 @@ export const contract = {
.input(RestoreBackupInput)
.output(z.void())
.errors({
BAD_REQUEST: { message: "Backup restoration failed" },
BAD_REQUEST: {
message: "Backup restoration failed",
data: appErrorData(AppErrorCode.BACKUP_RESTORE_FAILED),
},
}),
schedule: oc
.route({
@@ -616,7 +650,10 @@ export const contract = {
.input(TriggerJobInput)
.output(TriggerJobOutput)
.errors({
NOT_FOUND: { message: "Job not found" },
NOT_FOUND: {
message: "Job not found",
data: appErrorData(AppErrorCode.JOB_NOT_FOUND),
},
}),
purgeMetadataCache: oc
.route({
@@ -701,7 +738,13 @@ export const contract = {
successDescription: "Preview of importable items with counts",
})
.input(ParseFileInput)
.output(ImportPreviewSchema),
.output(ImportPreviewSchema)
.errors({
BAD_REQUEST: {
message: "Invalid import file",
data: appErrorData(AppErrorCode.IMPORT_INVALID_FILE),
},
}),
parsePayload: oc
.route({
method: "POST",
@@ -725,7 +768,17 @@ export const contract = {
successDescription: "Created import job",
})
.input(CreateImportJobInput)
.output(ImportJobSchema),
.output(ImportJobSchema)
.errors({
BAD_REQUEST: {
message: "Import payload too large",
data: appErrorData(AppErrorCode.IMPORT_PAYLOAD_TOO_LARGE),
},
CONFLICT: {
message: "An import is already in progress",
data: appErrorData(AppErrorCode.IMPORT_ALREADY_RUNNING),
},
}),
getJob: oc
.route({
method: "GET",
@@ -748,7 +801,13 @@ export const contract = {
successDescription: "Updated job state",
})
.input(IdParam)
.output(ImportJobSchema),
.output(ImportJobSchema)
.errors({
BAD_REQUEST: {
message: "Import cannot be cancelled",
data: appErrorData(AppErrorCode.IMPORT_CANNOT_CANCEL),
},
}),
jobEvents: oc
.route({
method: "GET",
+23
View File
@@ -0,0 +1,23 @@
import { z } from "zod";
export const AppErrorCode = {
TITLE_NOT_FOUND: "TITLE_NOT_FOUND",
PERSON_NOT_FOUND: "PERSON_NOT_FOUND",
INTEGRATION_NOT_FOUND: "INTEGRATION_NOT_FOUND",
BACKUP_NOT_FOUND: "BACKUP_NOT_FOUND",
BACKUP_DELETE_FAILED: "BACKUP_DELETE_FAILED",
BACKUP_RESTORE_FAILED: "BACKUP_RESTORE_FAILED",
JOB_NOT_FOUND: "JOB_NOT_FOUND",
TMDB_NOT_CONFIGURED: "TMDB_NOT_CONFIGURED",
IMPORT_INVALID_FILE: "IMPORT_INVALID_FILE",
IMPORT_PAYLOAD_TOO_LARGE: "IMPORT_PAYLOAD_TOO_LARGE",
IMPORT_ALREADY_RUNNING: "IMPORT_ALREADY_RUNNING",
IMPORT_CANNOT_CANCEL: "IMPORT_CANNOT_CANCEL",
REGISTRATION_CLOSED: "REGISTRATION_CLOSED",
} as const;
export type AppErrorCode = (typeof AppErrorCode)[keyof typeof AppErrorCode];
/** Typed error data schema for use in contract `.errors()` */
export const appErrorData = <T extends AppErrorCode>(code: T) =>
z.object({ code: z.literal(code) });
-1
View File
@@ -38,7 +38,6 @@
"@sofa/logger": "workspace:*",
"@sofa/tmdb": "workspace:*",
"adm-zip": "0.5.16",
"date-fns": "catalog:",
"node-vibrant": "4.0.4",
"sharp": "0.34.5",
"thumbhash": "catalog:",
+7 -3
View File
@@ -7,7 +7,11 @@ import { closeDatabase, db } from "@sofa/db/client";
import { sql } from "@sofa/db/helpers";
import { runMigrations } from "@sofa/db/migrate";
import { createLogger } from "@sofa/logger";
import { format } from "date-fns";
function formatTimestamp(date: Date): string {
const p = (n: number, len = 2) => String(n).padStart(len, "0");
return `${date.getFullYear()}-${p(date.getMonth() + 1)}-${p(date.getDate())}-${p(date.getHours())}${p(date.getMinutes())}${p(date.getSeconds())}${p(date.getMilliseconds(), 3)}`;
}
const log = createLogger("backup");
@@ -138,7 +142,7 @@ export function isValidBackupFilename(filename: string): boolean {
async function createBackupInternal(prefix: BackupPrefix): Promise<BackupInfo> {
await ensureBackupDir();
const timestamp = format(new Date(), "yyyy-MM-dd-HHmmssSSS");
const timestamp = formatTimestamp(new Date());
const filename = `${prefix}-${timestamp}.db`;
const dest = path.join(BACKUP_DIR, filename);
@@ -223,7 +227,7 @@ export async function restoreFromBackup(
const dbDir = path.dirname(DATABASE_URL);
await mkdir(dbDir, { recursive: true });
const timestamp = format(new Date(), "yyyy-MM-dd-HHmmssSSS");
const timestamp = formatTimestamp(new Date());
const tempPath = path.join(
dbDir,
`.restore-temp-${timestamp}-${crypto.randomUUID()}.db`,
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@sofa/i18n",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./locales": "./src/locales.ts",
"./format": "./src/format.ts",
"./test-utils": "./src/test-utils.tsx"
},
"scripts": {
"lint": "biome check",
"format": "biome format --write",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@lingui/core": "catalog:",
"@lingui/react": "catalog:"
},
"devDependencies": {
"@lingui/cli": "catalog:",
"@lingui/conf": "catalog:",
"@lingui/format-po": "catalog:",
"@types/bun": "catalog:",
"typescript": "catalog:"
}
}
+70
View File
@@ -0,0 +1,70 @@
import { i18n } from "./index";
/** Whether a string looks like a date-only value (no time component). */
function isDateOnly(value: string): boolean {
return /^\d{4}-\d{2}-\d{2}$/.test(value);
}
function toDate(date: Date | string): Date {
return typeof date === "string" ? new Date(date) : date;
}
export function formatDate(
date: Date | string,
options?: Intl.DateTimeFormatOptions,
): string {
// Date-only ISO strings (e.g. "1990-05-15") are parsed as UTC midnight.
// Format in UTC so users west of UTC don't see the previous day.
const useUTC =
typeof date === "string" && isDateOnly(date) && !options?.timeZone;
return new Intl.DateTimeFormat(i18n.locale, {
year: "numeric",
month: "long",
day: "numeric",
...(useUTC ? { timeZone: "UTC" } : {}),
...options,
}).format(toDate(date));
}
export function formatShortDate(date: Date | string): string {
return new Intl.DateTimeFormat(i18n.locale, {
month: "short",
day: "numeric",
year: "numeric",
}).format(toDate(date));
}
export function formatRelativeTime(date: Date | string): string {
const d = toDate(date);
const now = Date.now();
const diffMs = d.getTime() - now;
const absSec = Math.abs(Math.round(diffMs / 1000));
const absMin = Math.abs(Math.round(diffMs / 60_000));
const absHour = Math.abs(Math.round(diffMs / 3_600_000));
const absDay = Math.abs(Math.round(diffMs / 86_400_000));
const sign = diffMs < 0 ? -1 : 1;
const rtf = new Intl.RelativeTimeFormat(i18n.locale, { numeric: "auto" });
if (absSec < 60) return rtf.format(sign * absSec, "second");
if (absMin < 60) return rtf.format(sign * absMin, "minute");
if (absHour < 24) return rtf.format(sign * absHour, "hour");
if (absDay < 30) return rtf.format(sign * absDay, "day");
if (absDay < 365) return rtf.format(sign * Math.round(absDay / 30), "month");
return rtf.format(sign * Math.round(absDay / 365), "year");
}
export function formatNumber(
n: number,
options?: Intl.NumberFormatOptions,
): string {
return new Intl.NumberFormat(i18n.locale, options).format(n);
}
export function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = bytes / k ** i;
return `${new Intl.NumberFormat(i18n.locale, { maximumFractionDigits: 1 }).format(value)} ${sizes[i]}`;
}
+34
View File
@@ -0,0 +1,34 @@
import { i18n, type Messages } from "@lingui/core";
import { messages as enMessages } from "./po/en";
export const SUPPORTED_LOCALES = ["en", "fr", "de", "es", "it", "pt"] as const;
export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
// English always bundled — zero async on default locale
i18n.load("en", enMessages);
i18n.activate("en");
const loaders: Record<string, () => Promise<{ messages: Messages }>> = {
fr: () => import("./po/fr"),
de: () => import("./po/de"),
es: () => import("./po/es"),
it: () => import("./po/it"),
pt: () => import("./po/pt"),
};
export async function activateLocale(locale: SupportedLocale): Promise<void> {
if (locale === "en") {
i18n.activate("en");
return;
}
const loader = loaders[locale];
if (!loader) {
i18n.activate("en");
return;
}
const { messages } = await loader();
i18n.load(locale, messages);
i18n.activate(locale);
}
export { i18n };
+16
View File
@@ -0,0 +1,16 @@
import type { SupportedLocale } from "./index";
export interface LocaleInfo {
code: SupportedLocale;
name: string;
nativeName: string;
}
export const LOCALE_INFO: LocaleInfo[] = [
{ code: "en", name: "English", nativeName: "English" },
{ code: "fr", name: "French", nativeName: "Fran\u00e7ais" },
{ code: "de", name: "German", nativeName: "Deutsch" },
{ code: "es", name: "Spanish", nativeName: "Espa\u00f1ol" },
{ code: "it", name: "Italian", nativeName: "Italiano" },
{ code: "pt", name: "Portuguese", nativeName: "Portugu\u00eas" },
];
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import type { ReactNode } from "react";
import { messages } from "./po/en";
i18n.load("en", messages);
i18n.activate("en");
export function TestI18nProvider({ children }: { children: ReactNode }) {
return <I18nProvider i18n={i18n}>{children}</I18nProvider>;
}
export { i18n as testI18n };
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"esModuleInterop": true,
"types": ["bun"]
},
"include": ["src"]
}
+5
View File
@@ -6,6 +6,11 @@
"exports": {
".": "./src/index.ts"
},
"scripts": {
"lint": "biome check",
"format": "biome format --write",
"check-types": "tsc --noEmit"
},
"dependencies": {
"pino": "10.3.1",
"pino-pretty": "13.1.3"