# Reference-Data Scannability + Parity — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Make the three reference-data lists scannable (locale-aware sort + filter), show `external_uri` in read rows, add field-group count badges, and close the small parity/validation gaps — no layout/edit-modality change, no backend change.

**Architecture:** A shared `lib/sort.ts` (memoized `Intl.Collator` comparators) + a tiny `ExternalUriLink` are consumed by the vocab/authorities/fields list components. Each list gets a client-side filter `useState` + `<Input>`; rows are filtered then sorted before render.

**Tech Stack:** React 19 + TS + pnpm, react-i18next, Vitest + RTL + MSW. Test runner: `pnpm test` (single pass).

**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; en/sv parity (3 new keys); app source double-quote+semicolon; token classes only; **don't mutate query-cache arrays — sort a copy** (`[...list].sort(...)`).

**Spec:** `docs/superpowers/specs/2026-06-08-refdata-scannability-design.md`

**Key facts (from the code):**
- `labelText(labels, lang)` in `lib/labels.ts`. `lang = i18n.language.startsWith("sv") ? "sv" : "en"`.
- term/authority view = `{ id, labels, external_uri?: string|null }` (authority also `kind`); vocabulary = `{ id, key }`; field-def = `{ key, labels, data_type, group?, required, … }`.
- `term-row.tsx`/`authority-row.tsx`: read mode `<span className="flex-1">{labelText(...)}</span>` + Edit + DeleteConfirmDialog; edit mode has the `external_uri` `<Input>` (no `type`/placeholder).
- `vocabulary-list.tsx`: create form (has empty-guard) + list; rename `<form onSubmit>` calls `renameVocabulary.mutate({ id, key: draftKey.trim() })` with NO empty-guard.
- `authorities-page.tsx`: tabs + list + create form; create calls `create.mutate({ kind, external_uri: null, labels })` — hardcoded null, no URI field.
- `field-list.tsx`: groups built into a `Map`; sorted with `t("fields.other")` last (no A–Z); group header `<div className="… label-caption">{group}</div>`; rows show label+key+type+required.
- `common` i18n: `{ yes, no, close, loading }`. `labels`: `{ label, externalUri, otherLanguages }`.

---

# Task 1: Shared `sort.ts` + `ExternalUriLink` + i18n + unit test

**Files:** `web/src/lib/sort.ts` (new), `web/src/lib/sort.test.ts` (new), `web/src/components/external-uri-link.tsx` (new), `web/src/i18n/en.json`, `web/src/i18n/sv.json`.

- [ ] **Step 1: i18n (both locales, parity).**
  - `common`: add `"filter"` (en "Filter…" / sv "Filtrera…") and `"noMatches"` (en "No matches" / sv "Inga träffar").
  - `labels`: add `"uriPlaceholder": "https://…"` (same string in both).

- [ ] **Step 2: `web/src/lib/sort.ts`:**
```ts
import type { components } from "../api/schema";

import { labelText } from "./labels";

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

const collators = new Map<string, Intl.Collator>();

function collatorFor(lang: string): Intl.Collator {
  let c = collators.get(lang);
  if (!c) {
    c = new Intl.Collator(lang, { sensitivity: "base", numeric: true });
    collators.set(lang, c);
  }
  return c;
}

export function compareStrings(lang: string, a: string, b: string): number {
  return collatorFor(lang).compare(a, b);
}

export function byLabel(lang: string) {
  return (a: { labels: LabelView[] }, b: { labels: LabelView[] }) =>
    compareStrings(lang, labelText(a.labels, lang), labelText(b.labels, lang));
}

export function byKey(lang: string) {
  return (a: { key: string }, b: { key: string }) => compareStrings(lang, a.key, b.key);
}
```

- [ ] **Step 3: `web/src/lib/sort.test.ts`** (write failing first, then it passes with Step 2):
```ts
import { expect, test } from "vitest";
import { byKey, byLabel, compareStrings } from "./sort";

const L = (label: string) => ({ labels: [{ lang: "en", label }] });

test("byLabel sorts case-insensitively and locale-aware", () => {
  const sorted = [L("Iron"), L("bronze"), L("Amber")].sort(byLabel("en")).map((x) => x.labels[0].label);
  expect(sorted).toEqual(["Amber", "bronze", "Iron"]);
});

test("byKey sorts keys with numeric awareness", () => {
  const sorted = [{ key: "item10" }, { key: "item2" }, { key: "item1" }].sort(byKey("en")).map((x) => x.key);
  expect(sorted).toEqual(["item1", "item2", "item10"]);
});

test("compareStrings is case-insensitive", () => {
  expect(compareStrings("en", "bronze", "BRONZE")).toBe(0);
});
```
Run: `cd web && pnpm vitest run src/lib/sort.test.ts` → PASS (3 tests).

- [ ] **Step 4: `web/src/components/external-uri-link.tsx`** (app-source style):
```tsx
export function ExternalUriLink({ uri }: { uri: string }) {
  return (
    <a
      href={uri}
      target="_blank"
      rel="noopener noreferrer"
      className="block truncate text-xs text-muted-foreground hover:text-foreground"
    >
      {uri}
    </a>
  );
}
```

- [ ] **Step 5: Verify (vitest ONCE):** `cd web && pnpm vitest run src/lib/sort.test.ts && pnpm typecheck && pnpm lint`. PASS, clean.

- [ ] **Step 6: Commit**
```bash
git add web/src/lib/sort.ts web/src/lib/sort.test.ts web/src/components/external-uri-link.tsx web/src/i18n/en.json web/src/i18n/sv.json
git commit -m "feat(web): collator sort helpers + ExternalUriLink + filter/uri i18n (#50)"
```

---

# Task 2: Vocabularies — filter + sort + URI + rename guard

**Files:** `web/src/vocab/vocabulary-list.tsx`, `web/src/vocab/vocabulary-terms.tsx`, `web/src/vocab/term-row.tsx`, `web/src/vocab/vocabularies.test.tsx`.

- [ ] **Step 1: `vocabulary-list.tsx`** — add `i18n` to `useTranslation` (`const { t, i18n } = …`) and `const lang = i18n.language.startsWith("sv") ? "sv" : "en";`; import `byKey` from `../lib/sort` and `Input` is already imported. Add `const [filter, setFilter] = useState("");`.
  - Add a filter `<Input>` between the create `<form>` and the list block:
```tsx
<div className="border-b p-2">
  <Input
    aria-label={t("common.filter")}
    placeholder={t("common.filter")}
    value={filter}
    onChange={(e) => setFilter(e.target.value)}
  />
</div>
```
  - In the loaded `<ul>`, replace `{data?.map((v) => (…` with a filtered+sorted list:
```tsx
{(() => {
  const q = filter.trim().toLowerCase();
  const rows = [...(data ?? [])]
    .filter((v) => !q || v.key.toLowerCase().includes(q))
    .sort(byKey(lang));
  if (data && data.length > 0 && rows.length === 0)
    return <li className="p-3 text-sm text-muted-foreground">{t("common.noMatches")}</li>;
  return rows.map((v) => (/* the EXISTING <li> row markup, unchanged */));
})()}
```
  Keep the `isError`/empty(`data?.length === 0`) branches as-is. (Prefer a clean inline: compute `rows` before the `return`, but the IIFE keeps it local — implementer may hoist `const rows = …` above the JSX instead, whichever is cleaner and lint-clean.)
  - **Rename empty-guard:** in the rename `<form onSubmit>`, add a guard before mutate:
```tsx
onSubmit={(e) => {
  e.preventDefault();
  if (!draftKey.trim()) return;
  renameVocabulary.mutate({ id: v.id, key: draftKey.trim() }, { onSuccess: () => setEditingId(null) });
}}
```

- [ ] **Step 2: `vocabulary-terms.tsx`** — add `const [filter, setFilter] = useState("");`; import `byLabel` from `../lib/sort`. Add a filter `<Input>` above the terms list (the component already has `Input` imported; place it above the `{isLoading ? … : <ul>}` block). In the `<ul>`, replace `{terms?.map((term) => …}` with filtered (`labelText(term.labels, lang).toLowerCase().includes(q)`) + `.sort(byLabel(lang))`; filtered-empty (terms exist) → `common.noMatches` `<li>`. Add `type="url"` + `placeholder={t("labels.uriPlaceholder")}` to the add-term `external_uri` `<Input id="term-uri">`.

- [ ] **Step 3: `term-row.tsx`** — import `ExternalUriLink` from `../components/external-uri-link`. In read mode, wrap the label + URI in a flex-1 column so the URI shows under the label:
```tsx
<div className="flex-1">
  <div>{labelText(term.labels, lang)}</div>
  {term.external_uri && <ExternalUriLink uri={term.external_uri} />}
</div>
```
(Replace the existing `<span className="flex-1">{labelText(...)}</span>`; keep the Edit + DeleteConfirmDialog.) Add `type="url"` + `placeholder={t("labels.uriPlaceholder")}` to the edit-mode `external_uri` `<Input>`.

- [ ] **Step 4: Tests** (`vocabularies.test.tsx`) — extend (mirror the existing MSW/`tree()` setup; check the term/vocab fixtures for labels to assert order):
  - vocabularies render **sorted by key** (seed/confirm the fixture has out-of-order keys; assert DOM order).
  - typing in the vocab **filter** narrows the list (type a substring → only matching vocab shows).
  - **rename with an empty key** does NOT call the rename endpoint (override the PATCH/PUT handler with a spy; clear the rename input and submit; assert no request).
  - a **term read row shows its `external_uri`** as a link (seed a term fixture with `external_uri`; assert `getByRole("link", { name: /<uri>/ })`). (If the term fixture lacks a URI, add one — check `materialTerms` in `web/src/test/fixtures.ts`.)
  Keep existing vocab tests green. Don't weaken.

- [ ] **Step 5: Verify (vitest ONCE):** `cd web && pnpm vitest run src/vocab && pnpm typecheck && pnpm lint`. PASS.

- [ ] **Step 6: Commit**
```bash
git add web/src/vocab/vocabulary-list.tsx web/src/vocab/vocabulary-terms.tsx web/src/vocab/term-row.tsx web/src/vocab/vocabularies.test.tsx
git commit -m "feat(web): vocab list/terms sort+filter, external_uri in rows, rename guard, url input (#50)"
```

---

# Task 3: Authorities — filter + sort + create URI + read URI

**Files:** `web/src/authorities/authorities-page.tsx`, `web/src/authorities/authority-row.tsx`, `web/src/authorities/authorities.test.tsx`.

- [ ] **Step 1: `authorities-page.tsx`** — add `const [filter, setFilter] = useState("");` and `const [uri, setUri] = useState("");`; import `byLabel` from `../lib/sort` and `Input`/`Label` (`Input` may need importing — check; `Button` already imported).
  - **Filter `<Input>`** above the list block (below the tablist):
```tsx
<div className="mb-3">
  <Input aria-label={t("common.filter")} placeholder={t("common.filter")} value={filter} onChange={(e) => setFilter(e.target.value)} />
</div>
```
  - In the loaded `<ul>`, replace `{authorities?.map((a) => …}` with filtered (`labelText(a.labels, lang).toLowerCase().includes(q)`) + `.sort(byLabel(lang))`; filtered-empty (authorities exist) → `common.noMatches` `<li>`.
  - **Create form gains an `external_uri` field** (after the `<LabelEditor>`):
```tsx
<div className="space-y-1">
  <Label htmlFor="auth-create-uri">{t("labels.externalUri")}</Label>
  <Input id="auth-create-uri" type="url" placeholder={t("labels.uriPlaceholder")} value={uri} onChange={(e) => setUri(e.target.value)} />
</div>
```
  - In `onCreate`, send the URI and reset it:
```tsx
create.mutate(
  { kind: kind as string, external_uri: uri.trim() || null, labels },
  { onSuccess: () => { setLabels([]); setUri(""); } },
);
```

- [ ] **Step 2: `authority-row.tsx`** — same as term-row: import `ExternalUriLink`; read mode shows label + `ExternalUriLink` (when `authority.external_uri`) in a `flex-1` column; edit `external_uri` `<Input>` gets `type="url"` + `placeholder={t("labels.uriPlaceholder")}`.

- [ ] **Step 3: Tests** (`authorities.test.tsx`) — extend (mirror existing setup; check `personAuthorities` fixture):
  - list **sorted by label** (assert DOM order; ensure fixture has out-of-order labels or seed it).
  - **filter** narrows the list.
  - the **create form has an `external_uri` field** and a created authority **posts the entered URI** (spy the POST body; type a URI; assert `body.external_uri`).
  - a **read row shows the `external_uri`** link (seed a fixture authority with a URI).
  Keep existing authorities tests green (tabs, aria-selected, create-without-label alert, 500, redirect).

- [ ] **Step 4: Verify (vitest ONCE):** `cd web && pnpm vitest run src/authorities && pnpm typecheck && pnpm lint`. PASS.

- [ ] **Step 5: Commit**
```bash
git add web/src/authorities/authorities-page.tsx web/src/authorities/authority-row.tsx web/src/authorities/authorities.test.tsx
git commit -m "feat(web): authorities sort+filter, create external_uri, external_uri in rows, url input (#50)"
```

---

# Task 4: Fields — filter + within-group sort + group order + count badges + gate

**Files:** `web/src/fields/field-list.tsx`, `web/src/fields/fields.test.tsx`.

- [ ] **Step 1: `field-list.tsx`** — import `byLabel` and `compareStrings` from `../lib/sort` and `Badge` from `@/components/ui/badge`. Add `const [filter, setFilter] = useState("");` (add `useState` to the React import). After the early returns, before grouping:
  - Filter `data` by label/key: `const q = filter.trim().toLowerCase(); const filtered = (data ?? []).filter((d) => !q || labelText(d.labels, lang).toLowerCase().includes(q) || d.key.toLowerCase().includes(q));`
  - Build the `groups` Map from `filtered` (not `data`).
  - **Sort group entries** named A–Z (collator), `t("fields.other")` last:
```tsx
const otherLabel = t("fields.other");
const entries = [...groups.entries()].sort((a, b) => {
  if (a[0] === otherLabel) return 1;
  if (b[0] === otherLabel) return -1;
  return compareStrings(lang, a[0], b[0]);
});
```
  - **Sort each group's defs** by `byLabel(lang)`: when rendering `defs.map`, use `[...defs].sort(byLabel(lang)).map(...)`.
  - **Count badge** in each group header:
```tsx
<div className="flex items-center justify-between border-b bg-muted px-3 py-1 label-caption">
  <span>{group}</span>
  <Badge variant="secondary">{defs.length}</Badge>
</div>
```
  - Render a **filter `<Input>`** above the `<ul>` (wrap the return in a fragment/column): a `<div className="border-b p-2"><Input aria-label={t("common.filter")} placeholder={t("common.filter")} value={filter} onChange={(e) => setFilter(e.target.value)} /></div>` then the `<ul>`. If `filtered.length === 0` (data exists), show `<p className="p-3 text-sm text-muted-foreground">{t("common.noMatches")}</p>` instead of the `<ul>`. (Import `Input` from `@/components/ui/input`.)
  Note: the filter Input must remain visible during the empty-filter state so the user can clear it.

- [ ] **Step 2: Tests** (`fields.test.tsx`) — extend (mirror existing setup; `fieldDefinitions` fixture):
  - fields render **sorted within their group by label** (assert relative DOM order of two fields in the same group).
  - a **group header shows a count badge** (assert the count number is present near a group label).
  - typing in the **filter** narrows the visible fields (and/or shows `common.noMatches` when nothing matches).
  Keep the existing fields tests green (grouped list, create text field, reveal pickers).

- [ ] **Step 3: FULL GATE (run tests EXACTLY ONCE):**
```bash
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors
```
All green. Report test totals, largest chunk (KB gz), check:colors line.

- [ ] **Step 4: Codename + status:**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git grep -in 'biggus\|dickus' -- web/src; echo "codename-exit=$?"
git status --short
```

- [ ] **Step 5: Manual smoke (recommended).** `pnpm dev`: each reference list is alphabetized and has a working filter; term/authority rows show their external_uri as a muted link; field groups show counts; the authority create form has a URI field; renaming a vocab to empty does nothing; URI inputs are `type=url` with a placeholder.

- [ ] **Step 6: Commit**
```bash
git add web/src/fields/field-list.tsx web/src/fields/fields.test.tsx
git commit -m "feat(web): field-list filter, within-group label sort, group order, count badges (#50)"
```

---

## Self-Review (completed)

**Spec coverage:** sort.ts collator + byLabel/byKey + unit test (T1); ExternalUriLink + i18n (T1); vocab list/terms sort+filter + rename guard + read URI + url input (T2); authorities sort+filter + create URI + read URI + url input (T3); fields filter + within-group sort + group A–Z + count badges (T4); gate (T4). Acceptance criteria 1–5 mapped. ✓

**Placeholder scan:** the list-rewrite steps say "the EXISTING row markup, unchanged" with the files quoted, and tests say "check the fixture / seed a URI" with the fixture files named — concrete, not vague. The IIFE-vs-hoist choice is explicitly the implementer's (both lint-clean). No TODOs. ✓

**Type/consistency:** `byLabel(lang)`/`byKey(lang)`/`compareStrings(lang,a,b)` defined in T1, consumed in T2/T3/T4; `ExternalUriLink({uri})` used in term-row + authority-row; the filter `useState`/predicate pattern is uniform across the four lists; `[...arr].sort(...)` (copy, never mutate cache) everywhere. ✓

## Notes
- No new dependency; 3 new i18n keys (`common.filter`, `common.noMatches`, `labels.uriPlaceholder`), en+sv.
- Counts: only field-group counts (client-side). Per-vocab term & per-kind authority counts need backend → follow-up.
- Always sort a COPY of the react-query data (never mutate the cached array).
