docs(plans): instance locale + single-language content authoring (4 tasks)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,432 @@
|
||||
# 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):
|
||||
```rust
|
||||
/// 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`:
|
||||
```rust
|
||||
/// 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`:
|
||||
```rust
|
||||
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:
|
||||
```rust
|
||||
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`:
|
||||
```rust
|
||||
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`):
|
||||
```rust
|
||||
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 `mod`s) 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.**
|
||||
```bash
|
||||
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.**
|
||||
```bash
|
||||
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:
|
||||
```ts
|
||||
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`:
|
||||
```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`:
|
||||
```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):
|
||||
```tsx
|
||||
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).
|
||||
```bash
|
||||
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:
|
||||
```tsx
|
||||
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 test** — `web/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:
|
||||
```tsx
|
||||
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:48` — `getByLabelText(/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:
|
||||
```tsx
|
||||
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.
|
||||
```bash
|
||||
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** —
|
||||
```bash
|
||||
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: Frontend** — `pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size` (report bundle gz).
|
||||
|
||||
- [ ] **Step 3: Backend** —
|
||||
```bash
|
||||
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.
|
||||
Reference in New Issue
Block a user