Keep the multilingual content schema (dormant); simplify authoring inputs to one language. Default language + timezone via env vars (no settings table). Public /api/config endpoint surfaces them to the SPA. Per-account UI language deferred. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
10 KiB
Instance Locale (env-driven) + Single-Language Content Authoring — Design
Date: 2026-06-05 Status: Approved (brainstorming) — ready for implementation planning.
Context
The app is Sweden-first but the UI must stay translatable (English now; Danish/
Norwegian conceivable later). A question was raised: do we need the content-level
multilingual machinery (the lang-keyed label tables + LocalizedText field type), or
should we remove it to simplify the codebase?
The content-translation decision (keep the schema; simplify only the inputs)
Two different things are both called "translation":
- UI translation — react-i18next app chrome (sv/en JSON,
LangSwitch, localStorage persistence). Stays and grows. - Content multilingualism — the data:
domain::LocalizedLabel { lang, label }, theFieldType::LocalizedTextflexible-field type, and three DB tables keyed by(parent_id, lang):term_label,authority_label,field_definition_label.
Decision: keep the content schema; simplify only the authoring UI (brainstorm option A + the migration-risk analysis). Rationale:
- The expensive, hard-to-reverse part is the database schema. Removing it and adding it
back later means new migrations, backfilling every row with a language tag, rewriting
every db read/write (
vocab/authority/fields), changing API DTOs, swapping the frontend editor, and regenerating the typed client — a domain→db→api→web cross-cutting refactor. That is the exact pain to avoid. - The cheap, reversible part is the UI (one input vs two). Keeping the schema is nearly
free: a Sweden-only instance just stores
lang = <default>rows; re-enabling multilingual authoring later is "show the second input again" — zero migration. - The machinery is already built, tested, and merged (M2/M4). Removing it is also work + risk. Museum cataloguing commonly needs bilingual descriptive metadata (Spectrum/Europeana/loans), so "Sweden only" is the assumption most likely to change.
So this milestone keeps all multilingual capacity dormant in the schema and collapses the authoring inputs to a single language (the instance default).
Instance locale via environment variables (no settings table/page)
The app is single-tenant (no org/tenant table today — tables: object, audit_log, field_definition[+label], vocabulary, term[+label], authority[+label], app_user). The instance language and timezone are set-and-forget per deployment and never change at runtime, so they are environment variables, not a database settings table or an admin settings page. (If multi-org ever lands, this becomes per-org via a future migration — out of scope now.)
Timezone: always store UTC
Timestamps are already TIMESTAMPTZ (UTC); recording_date is a plain DATE. Storage and
transmission stay UTC — the API never localizes timestamps. The instance timezone is a
display/formatting concern only:
- Interactive UI → the frontend formats UTC → instance tz via
Intl.DateTimeFormat. - Server-rendered artifacts (PDF export #39, future reports/CLI) → the backend will need the tz to format without a browser. That server-side formatting is owned by #39; this milestone only stores and exposes the tz value.
Scope
In:
- Two backend config knobs:
default_language(DEFAULT_LANGUAGE, defaultsv) anddefault_timezone(DEFAULT_TIMEZONE, defaultEurope/Stockholm), inserver::ConfigandAppState. - A public
GET /api/configendpoint exposing{ app_name, default_language, default_timezone }. - Frontend config provider: fetch
/api/configon boot; applydefault_languageto i18n (when no stored preference); expose the values via context. - Single-language content authoring: collapse
LabelEditorand theLocalizedTextfield input to one input writing atdefault_language. Schema/DTOs unchanged.
Out (deferred / owned elsewhere):
- Per-account UI language (cross-device persistence: a
languagecolumn onapp_user, returned at login, SPA inits from it) → file as a follow-up issue. (Per-browser persistence already exists viaLangSwitch+ localStorage.) - Danish/Norwegian UI translations (just more i18n JSON when wanted).
- Server-side timezone formatting → PDF export (#39).
- A real org-settings page → only if multi-org lands.
Backend
Config (crates/server/src/config.rs)
Add to Config (clap derive, matching app_name):
/// Default UI + content-authoring language for this instance (BCP-47 / i18n key, e.g. "sv").
#[arg(long = "default-language", env = "DEFAULT_LANGUAGE", default_value = "sv")]
pub default_language: String,
/// Default display timezone (IANA name, e.g. "Europe/Stockholm"). Storage stays UTC;
/// this is a display hint surfaced to clients and (later) server-side renderers.
#[arg(long = "default-timezone", env = "DEFAULT_TIMEZONE", default_value = "Europe/Stockholm")]
pub default_timezone: String,
The timezone is treated as an opaque string (no backend tz library; validated client-side
by Intl). Thread both into AppState (alongside app_name).
AppState (crates/api/src/lib.rs)
Add pub default_language: String and pub default_timezone: String. server::run populates
them from Config.
Config endpoint (crates/api/src/ — e.g. a small config.rs or in health/public)
GET /api/config (unauthenticated)
→ 200 { "app_name": String, "default_language": String, "default_timezone": String }
#[derive(Serialize, ToSchema)] ConfigView, registered in openapi.rs. Unauthenticated
because the SPA needs it before login (so the login page renders in the instance language).
No secrets are exposed. Regenerate web/src/api/schema.d.ts.
Frontend
Config provider (web/src/)
- A
useConfigquery/hook fetchingGET /api/configonce on boot (TanStack Query, longstaleTime), exposing{ app_name, default_language, default_timezone }via context. - Language: i18n still inits synchronously with a safe fallback. After config loads, if
there is no
localStorage[LOCALE_KEY]preference, calli18n.changeLanguage(config.default_language). The existingLangSwitch+ localStorage override path is unchanged. (Net effect: a fresh browser defaults to the instance language; a user who has switched keeps their choice.) - Timezone: add a small
formatTimestamp(utc, config.default_timezone, locale)helper usingIntl.DateTimeFormat(locale, { timeZone }). Use it wherever timestamps are rendered. (During planning, audit the current UI for timestamp surfaces; if there are none yet, the helper + config value are forward-ready for PDF #39 / a future audit screen — do not invent displays.)
Single-language content authoring (web/src/components/label-editor.tsx, the LocalizedText field input in web/src/objects/field-input.tsx)
LabelEditor: replace the EN+SV pair with a single labelled text input.onChangeemits[{ lang: config.default_language, label }](omit empty). The "EN required / SV optional" rule collapses to "the single label is required."LocalizedTextfield input: replace the per-language inputs with a single textbox; the value written is{ [config.default_language]: text }.- Reading:
labelText/domain::pick_labelalready pick a language with fallback; set the preferred lang toconfig.default_language(then English, then first) so existing multilingual data (e.g. seeded) still displays. - Unchanged:
LabelInput/LabelViewDTOs, the three*_labeltables,LocalizedLabel,FieldType::LocalizedText, and all db read/write paths. Only the input components change.
Data flow
Boot → SPA fetches /api/config → context holds { app_name, default_language, default_timezone } → i18n language set to default_language (unless overridden in
localStorage) → content editors author one label at default_language (stored as a
single-entry [{lang,label}]) → timestamps render via Intl in default_timezone.
Error handling
/api/configis static, env-derived — it cannot fail at the DB level (no query). If the fetch fails (network), the SPA falls back to the synchronous i18n default and a sensible built-in timezone ("Europe/Stockholm"), and retries via TanStack Query.- Empty/invalid timezone: the frontend
Intlcall throws for an invalid IANA name; the helper catches and falls back to UTC display rather than crashing.
Testing
- Backend:
GET /api/configreturns the configured values; defaults applied when env unset; endpoint is reachable unauthenticated.ConfigparsesDEFAULT_LANGUAGE/DEFAULT_TIMEZONE(+--default-language/--default-timezone). - Frontend (Vitest + RTL + MSW): with a config of
{ default_language: "sv" }and no stored locale, i18n switches tosv; with a storeden, it staysen.LabelEditorrenders one input and emits[{ lang: "sv", label }]. ALocalizedTextfield input emits{ sv: text }. Existing screens that read labels still render. - en/sv i18n key parity; no
any/eslint-disable; codename ban; bundle ≤150 KB gz.
Acceptance criteria
DEFAULT_LANGUAGE/DEFAULT_TIMEZONEenv vars (with CLI flags + defaultssv/Europe/Stockholm) drive instance locale; no settings table or admin page.GET /api/config(public) returnsapp_name+default_language+default_timezone; inschema.d.ts.- The SPA defaults its UI language to the instance default (overridable per-browser via the
existing
LangSwitch/localStorage). - Content authoring is single-language:
LabelEditorandLocalizedTextinputs collapse to one field writing at the default language; the content schema/DTOs/tables are unchanged (multilingual capacity remains dormant, re-enabled by UI alone). - Timestamps render in the instance timezone via
Intlwhere displayed; storage stays UTC. - CI green (cargo + web typecheck/lint/test/build, bundle ≤150 KB); en/sv parity.
Out of scope / follow-ups
- Per-account UI language (cross-device) — separate issue (filed with this milestone).
- Danish/Norwegian UI locales; server-side tz formatting (#39); org-level settings page.