Files
biggus-dickus/docs/superpowers/plans/2026-06-05-instance-locale-content-authoring.md
T
2026-06-05 14:29:40 +02:00

21 KiB

Instance Locale + Single-Language Content Authoring Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use - [ ].

Goal: Drive instance UI/content language + display timezone from environment variables (no settings table), surface them to the SPA via a public GET /api/config, default the UI language from it, and collapse content authoring (LabelEditor + LocalizedText field input) to a single language — without touching the multilingual content schema (dormant, re-enabled by UI alone).

Architecture: Two server::Config env knobs (DEFAULT_LANGUAGE, DEFAULT_TIMEZONE) flow into AppState and a public ConfigView endpoint. A frontend ConfigProvider fetches it once, sets the i18n language (when no per-browser override), and feeds the default language to the simplified content inputs. Storage stays UTC; timezone is exposed but has no frontend formatter yet (no timestamp displays exist — deferred to its first consumer).

Tech Stack: Rust (axum, utoipa, clap), React + TS, react-i18next, TanStack Query, Vitest + RTL + MSW.

Spec: docs/superpowers/specs/2026-06-05-instance-locale-and-content-authoring-design.md

Conventions: nightly fmt; clippy -D warnings; no any/eslint-disable/@ts-ignore; en/sv i18n parity; codename ban; bundle ≤150 KB gz. Test infra: DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev, MEILI_URL=http://localhost:7700, MEILI_MASTER_KEY=masterKey. cargo from repo root; web from web/.


Task 1: Backend — config knobs + AppState + public GET /api/config + regen client

Files: Modify crates/server/src/config.rs, crates/server/src/lib.rs, crates/api/src/lib.rs, crates/api/src/openapi.rs; Create crates/api/src/config.rs; Modify all AppState { … } construction sites (server + api test harnesses); Test crates/api/tests/config.rs; Regenerate web/src/api/schema.d.ts.

  • Step 1: Config knobs. In crates/server/src/config.rs, add to Config (clap derive, matching app_name's style):
    /// Default UI + content-authoring language for this instance (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,
  • Step 2: AppState fields. In crates/api/src/lib.rs, add to pub struct AppState:
    /// Instance default UI/content language (from config).
    pub default_language: String,
    /// Instance default display timezone, IANA name (from config). Storage stays UTC.
    pub default_timezone: String,

In crates/server/src/lib.rs run, populate them when building AppState:

        default_language: config.default_language,
        default_timezone: config.default_timezone,

(place after app_name: config.app_name, — note these are moves; config fields are disjoint.)

  • Step 3: Update every other AppState { … } site. Run grep -rn "AppState {" crates/ — besides crates/api/src/lib.rs (the struct def) and server/src/lib.rs (done above), there are ~9 test state(...) helpers (crates/server/tests/serve.rs, crates/api/tests/{admin,admin_objects,admin_search,public,reindex,admin_catalog,admin_fields,health}.rs). Add to each literal:
        default_language: "sv".into(),
        default_timezone: "Europe/Stockholm".into(),

(The build will fail to compile until all are updated — that's the checklist.)

  • Step 4: Write the failing API test — create crates/api/tests/config.rs:
use api::{AppState, build_app};
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use sqlx::PgPool;
use tower::ServiceExt;

fn state(pool: PgPool) -> AppState {
    AppState {
        db: db::Db::from_pool(pool),
        app_name: "Test Museum".into(),
        cookie_secure: false,
        search: None,
        default_language: "sv".into(),
        default_timezone: "Europe/Stockholm".into(),
    }
}

#[sqlx::test(migrations = "../db/migrations")]
async fn config_is_public_and_reflects_state(pool: PgPool) {
    let app = build_app(state(pool));
    let resp = app
        .oneshot(Request::builder().uri("/api/config").body(Body::empty()).unwrap())
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body: serde_json::Value =
        serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
    assert_eq!(body["app_name"], "Test Museum");
    assert_eq!(body["default_language"], "sv");
    assert_eq!(body["default_timezone"], "Europe/Stockholm");
}
  • Step 5: Run → fails (/api/config 404): cargo test -p api --test config.

  • Step 6: Implement the endpoint — create crates/api/src/config.rs (mirror health.rs):

use axum::{Json, Router, extract::State, routing::get};
use serde::Serialize;
use utoipa::ToSchema;

use crate::AppState;

/// Public, non-sensitive instance configuration the SPA needs before login.
#[derive(Serialize, ToSchema)]
pub(crate) struct ConfigView {
    /// User-facing product name.
    pub app_name: String,
    /// Default UI/content language (i18n key, e.g. "sv").
    pub default_language: String,
    /// Default display timezone (IANA name). Storage is UTC; this is a display hint.
    pub default_timezone: String,
}

#[utoipa::path(get, path = "/api/config", responses((status = 200, body = ConfigView)))]
pub(crate) async fn get_config(State(state): State<AppState>) -> Json<ConfigView> {
    Json(ConfigView {
        app_name: state.app_name.clone(),
        default_language: state.default_language.clone(),
        default_timezone: state.default_timezone.clone(),
    })
}

pub(crate) fn routes() -> Router<AppState> {
    Router::new().route("/api/config", get(get_config))
}
  • Step 7: Register the module + route + schema.

    • crates/api/src/lib.rs: add mod config; (alphabetical with other mods) and .merge(config::routes()) in build_app (next to health::routes()).
    • crates/api/src/openapi.rs: add config to the use crate::{…} import; add config::get_config to paths(…); add config::ConfigView to components(schemas(…)).
  • Step 8: Run → passes. cargo test -p api --test config, then cargo +nightly fmt, cargo clippy --workspace --all-targets, and full DATABASE_URL=… MEILI_URL=… MEILI_MASTER_KEY=… cargo test -p api -p server (the AppState field additions compile everywhere).

  • Step 9: Regenerate the typed client.

cargo build -p server
lsof -ti :8080 | xargs kill 2>/dev/null
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey ./target/debug/server &
SERVER_PID=$!
sleep 2
( cd web && pnpm gen:api )
kill "$SERVER_PID"
grep -n "ConfigView\|api/config" web/src/api/schema.d.ts

Both must appear; diff additive. cd web && pnpm typecheck clean.

  • Step 10: Commit.
cd /Users/olsson/Laboratory/biggus-dickus
git add crates/server crates/api web/src/api/schema.d.ts
git commit -m "feat: DEFAULT_LANGUAGE/DEFAULT_TIMEZONE config + public GET /api/config"

Task 2: Frontend — config provider + i18n default wiring

Files: Create web/src/config/config-context.tsx; Modify web/src/main.tsx, web/src/test/handlers.ts; Test web/src/config/config-context.test.tsx.

  • Step 1: MSW handler. In web/src/test/handlers.ts, add to the handlers array a default config response:
  http.get("/api/config", () =>
    HttpResponse.json({
      app_name: "Test Museum",
      default_language: "sv",
      default_timezone: "Europe/Stockholm",
    }),
  ),
  • Step 2: Failing provider test — create web/src/config/config-context.test.tsx:
import { expect, test, beforeEach } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react";
import i18n from "../i18n";
import { LOCALE_KEY } from "../i18n";
import { ConfigProvider, useConfig } from "./config-context";

function Probe() {
  const config = useConfig();
  return <span data-testid="lang">{config.default_language}</span>;
}

function renderProvider() {
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return render(
    <QueryClientProvider client={qc}>
      <ConfigProvider><Probe /></ConfigProvider>
    </QueryClientProvider>,
  );
}

beforeEach(() => {
  localStorage.clear();
  void i18n.changeLanguage("en");
});

test("exposes config and applies default language when no stored preference", async () => {
  renderProvider();
  expect(await screen.findByText("sv")).toBeInTheDocument();
  await waitFor(() => expect(i18n.language).toBe("sv"));
});

test("a stored locale preference wins over the instance default", async () => {
  localStorage.setItem(LOCALE_KEY, "en");
  void i18n.changeLanguage("en");
  renderProvider();
  await screen.findByText("sv"); // config still loads
  await waitFor(() => expect(i18n.language).toBe("en")); // but language stays en
});
  • Step 3: Run → fails (module missing): cd web && pnpm test src/config/config-context.test.tsx.

  • Step 4: Implement the provider — create web/src/config/config-context.tsx:

import { createContext, useContext, useEffect, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";

import type { components } from "../api/schema";
import { api } from "../api/client";
import i18n, { LOCALE_KEY } from "../i18n";

type ConfigView = components["schemas"]["ConfigView"];

const DEFAULTS: ConfigView = {
  app_name: "Collection Management System",
  default_language: "sv",
  default_timezone: "Europe/Stockholm",
};

const ConfigContext = createContext<ConfigView>(DEFAULTS);

export function useConfig(): ConfigView {
  return useContext(ConfigContext);
}

export function ConfigProvider({ children }: { children: ReactNode }) {
  const { data } = useQuery({
    queryKey: ["config"],
    queryFn: async (): Promise<ConfigView> => {
      const { data, error } = await api.GET("/api/config");

      if (error || !data) throw new Error("failed to load config");

      return data;
    },
    staleTime: Infinity,
  });

  // Default the UI language to the instance default, unless the user has chosen one
  // for this browser (LangSwitch persists to localStorage[LOCALE_KEY]).
  useEffect(() => {
    if (data && !localStorage.getItem(LOCALE_KEY)) {
      void i18n.changeLanguage(data.default_language);
    }
  }, [data]);

  return <ConfigContext.Provider value={data ?? DEFAULTS}>{children}</ConfigContext.Provider>;
}
  • Step 5: Run → passes. pnpm test src/config/config-context.test.tsx.

  • Step 6: Mount the provider. In web/src/main.tsx, wrap <App /> (inside QueryClientProvider, since the provider uses TanStack Query):

import { ConfigProvider } from "./config/config-context";
// ...
    <QueryClientProvider client={queryClient}>
      <ConfigProvider>
        <App />
      </ConfigProvider>
    </QueryClientProvider>
  • Step 7: Verify + commit. pnpm test && pnpm typecheck && pnpm lint && pnpm build. All green (existing tests unaffected — MSW now answers /api/config so onUnhandledRequest:"error" stays happy app-wide).
cd /Users/olsson/Laboratory/biggus-dickus
git add web
git commit -m "feat(web): config provider — fetch /api/config, default UI language from instance"

Task 3: Frontend — single-language content authoring

Files: Modify web/src/components/label-editor.tsx, web/src/objects/field-input.tsx, web/src/i18n/{en,sv}.json, web/src/components/label-editor.test.tsx, web/src/vocab/vocabularies.test.tsx, web/src/fields/fields.test.tsx, web/src/authorities/authorities.test.tsx.

The content schema, DTOs (LabelInput/LabelView), DB tables, LocalizedLabel, and FieldType::LocalizedText are unchanged. Only the input components collapse to one language. Reading/display (labelText/pick_label) already falls back (UI lang → en → first), so single-language data still renders — no change to the read path.

  • Step 1: i18n key. Add labels.label to BOTH web/src/i18n/en.json and sv.json:

    • en labels: "label": "Label"
    • sv labels: "label": "Etikett" (Keep the existing labels.en/labels.sv/labels.externalUri keys — externalUri is still used; labels.en/labels.sv may become unused after this task — if pnpm lint/grep shows them unreferenced, remove them from BOTH files to keep parity, else leave.)
  • Step 2: Collapse LabelEditor — replace web/src/components/label-editor.tsx body:

import { useTranslation } from "react-i18next";

import type { components } from "../api/schema";
import { useConfig } from "../config/config-context";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

type LabelInput = components["schemas"]["LabelInput"];

/** Single-language label editor. Authors one label at the instance default language;
 *  emits a one-entry LabelInput[] (empty array when blank). The multilingual data model
 *  is unchanged — this only simplifies authoring. */
export function LabelEditor({
  value,
  onChange,
}: {
  value: LabelInput[];
  onChange: (labels: LabelInput[]) => void;
}) {
  const { t } = useTranslation();
  const { default_language } = useConfig();

  const current =
    value.find((l) => l.lang === default_language)?.label ?? value[0]?.label ?? "";

  const set = (label: string) =>
    onChange(label.trim() ? [{ lang: default_language, label }] : []);

  return (
    <div className="space-y-1">
      <Label htmlFor="label">{t("labels.label")}</Label>
      <Input id="label" value={current} onChange={(e) => set(e.target.value)} />
    </div>
  );
}
  • Step 3: Update LabelEditor's own testweb/src/components/label-editor.test.tsx currently types into /label \(en\)/i + /label \(sv\)/i and asserts both langs. Rewrite it for the single input (it must render under a ConfigProvider so useConfig works — wrap with the test's existing renderApp/provider, adding ConfigProvider; the MSW /api/config handler returns default_language: "sv"). New test:
import { useState } from "react";
import { expect, test } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderApp } from "../test/render";
import { ConfigProvider } from "../config/config-context";
import { LabelEditor } from "./label-editor";
import type { components } from "../api/schema";

type LabelInput = components["schemas"]["LabelInput"];

function Harness({ onChange }: { onChange: (v: LabelInput[]) => void }) {
  const [value, setValue] = useState<LabelInput[]>([]);
  return <LabelEditor value={value} onChange={(v) => { setValue(v); onChange(v); }} />;
}

test("emits a single label at the instance default language", async () => {
  const seen: LabelInput[][] = [];
  renderApp(<ConfigProvider><Harness onChange={(v) => seen.push(v)} /></ConfigProvider>);
  // config (default_language "sv") must load before the editor authors
  await screen.findByLabelText(/^label$/i);
  await userEvent.type(screen.getByLabelText(/^label$/i), "Brons");
  await waitFor(() => {
    const last = seen[seen.length - 1]!;
    expect(last).toEqual([{ lang: "sv", label: "Brons" }]);
  });
});

NOTE: if renderApp doesn't already provide a QueryClientProvider that ConfigProvider needs, check web/src/test/render.tsx — it does wrap QueryClientProvider (the vocab/search tests rely on it). The MSW /api/config default handler (Task 2) supplies the config.

  • Step 4: Update the consumer tests. The forms that use LabelEditor have tests typing into /label \(en\)/i. They now render a single /^label$/i input writing sv. Update each:

    • web/src/vocab/vocabularies.test.tsx:48getByLabelText(/label \(en\)/i)getByLabelText(/^label$/i). These tests render the full app/route tree which must include ConfigProvider for useConfig — check renderApp/the test tree; if the tree doesn't wrap ConfigProvider, wrap the rendered subtree in <ConfigProvider> (the MSW /api/config handler answers). Adjust any assertion expecting an EN/SV pair to the single sv label.
    • web/src/fields/fields.test.tsx (3 sites: lines ~38, ~58, ~79) — same getByLabelText(/^label$/i) swap + wrap ConfigProvider if needed.
    • web/src/authorities/authorities.test.tsx:28 — same. Run each file and fix selector/provider issues until green.
  • Step 5: Collapse the LocalizedText field input — in web/src/objects/field-input.tsx, the case "localized_text": block renders ${key}.en + ${key}.sv inputs. Replace with a single input registering ${key}.${default_language}. Add const { default_language } = useConfig(); near the top of the FieldInput component (alongside the existing const lang = …). New case:

    case "localized_text":
      return (
        <div className="space-y-1">
          <Label htmlFor={definition.key}>{label}</Label>
          <Input
            id={definition.key}
            {...form.register(fieldPath<TValues>(`${definition.key}.${default_language}`), {
              required: definition.required,
            })}
          />
        </div>
      );

(Imports: useConfig from ../config/config-context.) The stored value remains a { lang: text } map — now { [default_language]: text }. The field-input.test.tsx may reference the EN/SV localized inputs — update it to the single input (register path ${key}.${default_language}), wrapping with ConfigProvider if the test renders the component directly.

  • Step 6: Verify + commit. cd web && pnpm test && pnpm typecheck && pnpm lint && pnpm build && pnpm check:size. All green; bundle ≤150 KB. en/sv parity holds.
cd /Users/olsson/Laboratory/biggus-dickus
git add web
git commit -m "feat(web): single-language content authoring (LabelEditor + localized_text at default lang)"

Task 4: Verification

  • Step 1: i18n parity
cd web
node -e "const a=require('./src/i18n/en.json'),b=require('./src/i18n/sv.json');const k=o=>Object.entries(o).flatMap(([K,v])=>typeof v==='object'?k(v).map(s=>K+'.'+s):[K]);const ka=k(a).sort(),kb=k(b).sort();console.log(JSON.stringify(ka)===JSON.stringify(kb)?'PARITY OK':'MISMATCH '+JSON.stringify({onlyEn:ka.filter(x=>!kb.includes(x)),onlySv:kb.filter(x=>!ka.includes(x))}))"

Expected PARITY OK.

  • Step 2: Frontendpnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size (report bundle gz).

  • Step 3: Backend

cd /Users/olsson/Laboratory/biggus-dickus
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test --workspace
cargo clippy --workspace --all-targets
cargo +nightly fmt --check

All pass; clippy + fmt clean.

  • Step 4: Acceptance spot-checks.
    • cargo run -p server -- --help | grep -E "default-language|default-timezone" shows both flags.
    • Content schema untouched: git diff main..HEAD -- crates/db/migrations crates/domain/src/label.rs is empty (no schema/domain label changes).
    • git grep -in 'biggus\|dickus' -- crates web/src → none.

Self-Review (completed)

  • Spec coverage: env knobs + AppState → T1; public /api/config → T1; config provider + i18n default → T2; single-language LabelEditor + LocalizedText → T3; UTC storage unchanged (no timestamp code touched); timezone exposed (no formatter — no consumer, per spec's "forward-ready if none"); parity/bundle → T4. ✓ Per-account UI language + da/no + server-side tz are out of scope (issue #40 / #39). ✓
  • Placeholder scan: none — concrete code; the "wrap ConfigProvider if the test tree doesn't already" notes are real verification steps against named files (the provider dependency is new, so tests that mount label-authoring components need it).
  • Type consistency: ConfigView { app_name, default_language, default_timezone } is the single shape across the Rust struct, the components["schemas"]["ConfigView"] TS type, the provider DEFAULTS, and the MSW handler; LabelEditor still emits LabelInput[] (one entry); default_language threaded from useConfig() consistently in both the editor and the field input.

Notes

  • Timezone has no frontend consumer yet (no timestamp is displayed — only recording_date, a plain DATE). The value is exposed via /api/config + useConfig so PDF export (#39) and any future audit/timestamp view can format with it; building a formatTimestamp helper now would be unused (YAGNI).
  • AppState gained two fields → every AppState { … } literal (incl. all api/server test harnesses) must add them or the workspace won't compile; Task 1 Step 3 enumerates this.