Files
biggus-dickus/docs/superpowers/specs/2026-06-05-instance-locale-and-content-authoring-design.md
logaritmisk 0f43c75b24 docs(specs): instance locale (env) + single-language content authoring
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>
2026-06-05 14:23:32 +02:00

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 }, the FieldType::LocalizedText flexible-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:

  1. Two backend config knobs: default_language (DEFAULT_LANGUAGE, default sv) and default_timezone (DEFAULT_TIMEZONE, default Europe/Stockholm), in server::Config and AppState.
  2. A public GET /api/config endpoint exposing { app_name, default_language, default_timezone }.
  3. Frontend config provider: fetch /api/config on boot; apply default_language to i18n (when no stored preference); expose the values via context.
  4. Single-language content authoring: collapse LabelEditor and the LocalizedText field input to one input writing at default_language. Schema/DTOs unchanged.

Out (deferred / owned elsewhere):

  • Per-account UI language (cross-device persistence: a language column on app_user, returned at login, SPA inits from it) → file as a follow-up issue. (Per-browser persistence already exists via LangSwitch + 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 useConfig query/hook fetching GET /api/config once on boot (TanStack Query, long staleTime), 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, call i18n.changeLanguage(config.default_language). The existing LangSwitch + 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 using Intl.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. onChange emits [{ lang: config.default_language, label }] (omit empty). The "EN required / SV optional" rule collapses to "the single label is required."
  • LocalizedText field input: replace the per-language inputs with a single textbox; the value written is { [config.default_language]: text }.
  • Reading: labelText / domain::pick_label already pick a language with fallback; set the preferred lang to config.default_language (then English, then first) so existing multilingual data (e.g. seeded) still displays.
  • Unchanged: LabelInput/LabelView DTOs, the three *_label tables, 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/config is 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 Intl call throws for an invalid IANA name; the helper catches and falls back to UTC display rather than crashing.

Testing

  • Backend: GET /api/config returns the configured values; defaults applied when env unset; endpoint is reachable unauthenticated. Config parses DEFAULT_LANGUAGE / DEFAULT_TIMEZONE (+ --default-language / --default-timezone).
  • Frontend (Vitest + RTL + MSW): with a config of { default_language: "sv" } and no stored locale, i18n switches to sv; with a stored en, it stays en. LabelEditor renders one input and emits [{ lang: "sv", label }]. A LocalizedText field 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

  1. DEFAULT_LANGUAGE / DEFAULT_TIMEZONE env vars (with CLI flags + defaults sv / Europe/Stockholm) drive instance locale; no settings table or admin page.
  2. GET /api/config (public) returns app_name + default_language + default_timezone; in schema.d.ts.
  3. The SPA defaults its UI language to the instance default (overridable per-browser via the existing LangSwitch/localStorage).
  4. Content authoring is single-language: LabelEditor and LocalizedText inputs 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).
  5. Timestamps render in the instance timezone via Intl where displayed; storage stays UTC.
  6. 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.