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>
This commit is contained in:
@@ -0,0 +1,176 @@
|
|||||||
|
# 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`):
|
||||||
|
```rust
|
||||||
|
/// 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.
|
||||||
Reference in New Issue
Block a user