Skip to content

Frontend Internationalisation (i18n)

TaskWolf's frontend is translated with react-i18next. This page documents the conventions established by the i18n pilot (nav chrome, auth, and core account settings) so follow-up cycles migrate the remaining pages the same way.

Where It Lives

frontend/src/i18n/
  index.ts               # i18next init: languages, namespaces, detection
  format.ts               # Intl-based date/number/relative-time helpers
  locales/
    en/
      common.json
      nav.json
      auth.json
      settings.json
    de/
      common.json
      nav.json
      auth.json
      settings.json

frontend/src/i18n/index.ts configures the instance:

export const SUPPORTED_LANGUAGES = ['en', 'de'] as const
export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number]

i18n
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    resources: {
      en: { common: enCommon, settings: enSettings, nav: enNav, auth: enAuth },
      de: { common: deCommon, settings: deSettings, nav: deNav, auth: deAuth },
    },
    fallbackLng: 'en',
    supportedLngs: [...SUPPORTED_LANGUAGES],
    nonExplicitSupportedLngs: true,
    defaultNS: 'common',
    ns: ['common', 'settings', 'nav', 'auth'],
    interpolation: { escapeValue: false },
    detection: {
      order: ['localStorage', 'navigator'],
      lookupLocalStorage: 'taskowolf.lang',
      caches: ['localStorage'],
    },
  })

fallbackLng is 'en'; supportedLngs is ['en', 'de']. Language detection checks localStorage['taskowolf.lang'] first, then the browser's navigator language, and always caches the resolved language back to that same localStorage key. A languageChanged listener keeps document.documentElement.lang in sync for accessibility/SEO.

Namespaces

One namespace per feature area, each with an en and a de JSON file that are key-identical (same keys, same nesting, translated values):

Namespace File Holds
common (default) locales/{en,de}/common.json Shared words used across the app: save, cancel, saving, saved, loading, error
nav locales/{en,de}/nav.json Sidebar section headers and nav item labels, logout, sidebar expand/collapse
auth locales/{en,de}/auth.json Login and register pages
settings locales/{en,de}/settings.json Profile, security, account, notification-preferences settings

common is defaultNS, so an unqualified t('save') resolves there without specifying a namespace.

Keys

Keys are semantic and hierarchical — never the English source text:

// locales/en/settings.json
{
  "profile": {
    "title": "Profile",
    "displayName": "Display name",
    "updateFailed": "Failed to update profile"
  }
}
// locales/de/settings.json
{
  "profile": {
    "title": "Profil",
    "displayName": "Anzeigename",
    "updateFailed": "Profil konnte nicht aktualisiert werden"
  }
}

Group related strings under a feature-section object (profile, security, account, notifications inside settings; login, register inside auth) so a page's keys read as t('profile.displayName'), t('login.ssoButton'), etc.

Usage

import { useTranslation } from 'react-i18next'

export function ProfilePage() {
  const { t } = useTranslation('settings')
  const { t: tc } = useTranslation('common')   // pull in `common` alongside the page's own namespace

  return (
    <>
      <h1>{t('profile.title')}</h1>
      <button>{updatePending ? tc('saving') : tc('save')}</button>
    </>
  )
}

Call useTranslation('<namespace>') once per component for the page's primary namespace; alias a second call (t: tc) when you also need common strings in the same component.

Registering a new namespace — when a migration needs a namespace that doesn't exist yet:

  1. Create locales/en/<namespace>.json and locales/de/<namespace>.json, key-identical.
  2. In frontend/src/i18n/index.ts, import both files and add the namespace to resources.en, resources.de, and the ns array.

Interpolation and Plurals

Pass variables, never concatenate translated fragments:

// auth.json
{ "login": { "ssoButton": "Sign in with {{name}}" } }
t('login.ssoButton', { name: provider.displayName })

interpolation.escapeValue is false (React already escapes JSX output), so interpolated values render as-is — do not pass raw HTML through an interpolated variable.

For plurals, i18next (v26, CLDR-based) resolves the key by suffix from a count variable — key_one / key_other (English/German both only need those two forms), e.g.:

{ "issues": { "count_one": "{{count}} issue", "count_other": "{{count}} issues" } }
t('issues.count', { count: issues.length })

No page uses plurals yet — apply this convention only when a migrated string genuinely needs to vary by count.

Formatting Dates, Numbers, and Relative Times

Never hand-roll Date/Number formatting. Use the helpers in frontend/src/i18n/format.ts, which read the active i18next language and delegate to Intl:

import { formatDate, formatDateTime, formatNumber, formatRelativeTime } from '@/i18n/format'

formatDate(issue.createdAt)          // e.g. "Jul 12, 2026" / "12. Juli 2026"
formatDateTime(comment.createdAt)    // adds a short time component
formatNumber(project.issueCount)     // locale-aware thousands separators
formatRelativeTime(notification.createdAt)  // "3 hours ago" / "vor 3 Stunden"

Language Persistence

Two layers, so the choice is instant locally and durable across devices:

  1. localStorage (immediate). i18n.changeLanguage(lng) writes taskowolf.lang and updates <html lang> synchronously — the UI never waits on a network round trip.
  2. Backend (PATCH /api/v1/me/language, best-effort). useUpdateLanguage() (frontend/src/hooks/useMe.ts) persists the choice server-side so it follows the user to a new device/browser.
  3. useLanguageSync() (frontend/src/hooks/useLanguageSync.ts) is called once from AppLayout. It applies the user's server-stored language once after /me loads, if it differs from the current language (which the detector has already resolved from localStorage if one was set) — guarded by a ref so it only ever runs once per session.

The LanguageSwitcher component (frontend/src/components/LanguageSwitcher.tsx) drives both layers from one <select> and lives in Settings → Profile:

const onChange = (lng: string) => {
  void i18n.changeLanguage(lng)   // localStorage + <html lang>, immediate
  updateLanguage.mutate(lng)      // backend persistence, best-effort, non-blocking
}

Supported Languages / Fallback

SUPPORTED_LANGUAGES = ['en', 'de'] as const (exported from frontend/src/i18n/index.ts, with the AppLanguage union type derived from it) is the single source of truth for which languages exist — the LanguageSwitcher options and the useLanguageSync allow-list both read from it. fallbackLng is 'en'.

Migration Recipe (Follow-Up Cycles)

The pilot covered nav chrome, auth (login/register), and core account settings (profile, security, account, notifications). Every other page is still hardcoded English pending migration. To migrate a page:

  1. Pick a page (or a cohesive slice of one).
  2. Read through it and list every user-facing literal string.
  3. Add to (or create) that feature area's namespace in both locales/en/<ns>.json and locales/de/<ns>.json — the English value should initially be verbatim the current hardcoded copy, and the German value should be a real translation, not a placeholder.
  4. If it's a new namespace, register it in frontend/src/i18n/index.ts (see Registering a new namespace above).
  5. Replace the literals with t('...') calls (useTranslation('<ns>')), using dates/numbers/relative-times via format.ts instead of hand-rolled formatting.
  6. Run npm run build in frontend/ to catch missing-import/typecheck errors.
  7. Manually switch languages via the Settings → Profile language switcher and eyeball the page in both en and de.

Common Pitfalls

  • Never use the English text as a translation key. Keys are semantic (profile.displayName), not t('Display name') — an English-as-key approach breaks the moment the English copy is edited.
  • Never concatenate translated fragments. Build the full sentence in each locale file with {{variable}} interpolation instead of gluing t() calls together with string concatenation — word order differs between languages.
  • Don't hand-roll date/number formatting. Use formatDate/formatDateTime/formatNumber/formatRelativeTime from frontend/src/i18n/format.ts so formatting follows the active locale automatically.
  • Keep en/de JSON files key-identical. A key present in one locale and missing in the other silently falls back to fallbackLng (en) at runtime instead of failing loudly — diff the two files when reviewing a namespace change.
  • Don't add a namespace without registering it. A useTranslation('newns') call with an unregistered namespace renders raw keys instead of translated text.