# Object Detail Readability — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.

**Goal:** Make `web/src/objects/object-detail.tsx` readable: resolve term/authority ids → labels and `localized_text` → the active-language string, group flexible fields by `def.group` in definition order, and polish (date formatting, empty-core "—", an Edit/Delete actions toolbar).

**Architecture:** A new per-field `FlexibleFieldValue` component switches on `def.data_type` and resolves via the existing `useTerms`/`useAuthorities` + `labelText` (one hook call per component instance → rules-of-hooks safe; react-query dedups repeated vocabularies). `object-detail.tsx` iterates `definitions` (stable order) for grouping and renders core fields with placeholders. Frontend-only, no backend change.

**Tech Stack:** React 19 + TS + pnpm, react-i18next, TanStack Query, Vitest+RTL+MSW, Storybook 10.

**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; component source double-quote+semicolon, stories single-quote+no-semicolon; en/sv parity; no codename; tests query portals via `within(document.body)` (n/a here). `check:size` budget 180 KB gz (this is frontend-only, ~no bundle change).

**Spec:** `docs/superpowers/specs/2026-06-07-object-detail-readability-design.md`

**Facts:** flexible values in `object.fields` are: term/authority = UUID **string**, localized_text = `{lang: text}` **object**, others = primitive. `FieldDefinitionView` has `data_type`/`vocabulary_id`/`authority_kind`/`group`/`labels`/`key`. Helpers: `labelText(labels, lang)` (`web/src/lib/labels.ts`); hooks `useTerms(vocabularyId)` / `useAuthorities(kind)` (`web/src/api/queries.ts`). Core labels exist under `fieldsLabels.*`. `buttonVariants` is exported from `@/components/ui/button`. Test fixtures (`web/src/test/fixtures.ts`) have `fieldDefinitions` (covering all types), `materialTerms`, `personAuthorities`.

---

## Task 1: `FlexibleFieldValue` component + story + unit test
**Files:** create `web/src/objects/flexible-field-value.tsx`, `flexible-field-value.stories.tsx`, `flexible-field-value.test.tsx`. Modify `web/src/i18n/{en,sv}.json`.

- [ ] **Step 1: i18n keys.** Add to **both** locales: a `common` block `{ "yes": "Yes"/"Ja", "no": "No"/"Nej" }` and `objects.unknownRef` ("(unknown)" / "(okänd)").

- [ ] **Step 2: Write the component** `web/src/objects/flexible-field-value.tsx`:
```tsx
import { useTranslation } from "react-i18next";

import type { components } from "../api/schema";
import { useTerms, useAuthorities } from "../api/queries";
import { labelText } from "../lib/labels";

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

/** Renders one flexible field value as human-readable text, resolving term/authority ids
 *  to labels and localized_text to the active language. */
export function FlexibleFieldValue({
  def,
  value,
  lang,
}: {
  def: FieldDefinitionView;
  value: unknown;
  lang: string;
}) {
  switch (def.data_type) {
    case "term":
      return <TermValue vocabularyId={def.vocabulary_id} value={value} lang={lang} />;
    case "authority":
      return <AuthorityValue kind={def.authority_kind} value={value} lang={lang} />;
    case "localized_text":
      return <>{pickLocalized(value, lang)}</>;
    case "date":
      return <>{formatDate(value, lang)}</>;
    case "boolean":
      return <BooleanValue value={value} />;
    default:
      return <>{value == null ? "—" : String(value)}</>;
  }
}

function TermValue({ vocabularyId, value, lang }: { vocabularyId: string | null; value: unknown; lang: string }) {
  const { t } = useTranslation();
  const { data: terms, isLoading } = useTerms(vocabularyId ?? undefined);

  if (typeof value !== "string") return <>—</>;
  const term = terms?.find((x) => x.id === value);
  if (term) return <>{labelText(term.labels, lang)}</>;
  if (isLoading) return <span className="text-neutral-400">…</span>;
  return <span className="text-neutral-400">{value} {t("objects.unknownRef")}</span>;
}

function AuthorityValue({ kind, value, lang }: { kind: string | null; value: unknown; lang: string }) {
  const { t } = useTranslation();
  const { data: authorities, isLoading } = useAuthorities(kind ?? undefined);

  if (typeof value !== "string") return <>—</>;
  const authority = authorities?.find((x) => x.id === value);
  if (authority) return <>{labelText(authority.labels, lang)}</>;
  if (isLoading) return <span className="text-neutral-400">…</span>;
  return <span className="text-neutral-400">{value} {t("objects.unknownRef")}</span>;
}

function BooleanValue({ value }: { value: unknown }) {
  const { t } = useTranslation();
  return <>{value ? t("common.yes") : t("common.no")}</>;
}

function pickLocalized(value: unknown, lang: string): string {
  if (value && typeof value === "object" && !Array.isArray(value)) {
    const map = value as Record<string, string>;
    return map[lang] ?? map.en ?? Object.values(map)[0] ?? "—";
  }
  return value == null ? "—" : String(value);
}

function formatDate(value: unknown, lang: string): string {
  if (typeof value !== "string") return value == null ? "—" : String(value);
  // Parse as local midnight so a date-only value isn't shifted a day by tz when formatted.
  const date = new Date(`${value}T00:00:00`);
  if (Number.isNaN(date.getTime())) return value;
  return new Intl.DateTimeFormat(lang, { dateStyle: "medium" }).format(date);
}
```
Confirm `useTerms`/`useAuthorities` accept `undefined` and short-circuit (they have `enabled: !!arg`) — yes; passing `undefined` disables the query and `data` is undefined → falls through to the `—`/`…` paths. Confirm `TermView`/`AuthorityView` have `id` + `labels` (they do).

- [ ] **Step 3: Unit test** `flexible-field-value.test.tsx` (RTL + MSW + a QueryClient wrapper; mirror existing component tests). Use fixtures `materialTerms`/`personAuthorities`/`fieldDefinitions`. Cover: a `term` def + a value that is a known term id → renders the label (e.g. "Bronze"); `authority` → label; an unknown term id → renders `<id> (unknown)`; `localized_text` `{sv:"…",en:"…"}` with lang sv → the sv string; `date` "2024-01-05" → a formatted date (assert it's not the raw ISO); `boolean` true → "Yes". MSW must serve `/api/admin/vocabularies/{id}/terms` and `/api/admin/authorities?kind=` (reuse `web/src/test/handlers.ts` patterns).

- [ ] **Step 4: Run the unit test.** `cd web && pnpm test -- flexible-field-value`. Iterate to green (genuine assertions — label not id; not vacuous).

- [ ] **Step 5: Storybook** `flexible-field-value.stories.tsx` (mirror `web/src/objects/visibility-badge.stories.tsx`): stories `Term`, `Authority`, `LocalizedText`, `Date`, `Boolean`, `UnknownRef`. The term/authority stories need the hooks' data — rely on the preview's MSW (`src/test/handlers.ts`) serving terms/authorities, passing a `def` with the matching `vocabulary_id`/`authority_kind` and a `value` that's a known id. Assert the resolved label text shows.

- [ ] **Step 6:** `pnpm typecheck && pnpm lint`. **Commit** `feat(web): FlexibleFieldValue — resolve term/authority/localized field values (#45)`.

---

## Task 2: Refactor `object-detail.tsx` (grouping, placeholders, toolbar) + tests
**Files:** `web/src/objects/object-detail.tsx`, `web/src/objects/object-detail.test.tsx` (create if absent).

- [ ] **Step 1: Failing/updated detail test.** In `object-detail.test.tsx` (RTL + MSW + MemoryRouter at `/objects/:id`, providers from the test harness), seed an object whose `fields` include a term (material → a known term id), a localized_text, and a date; assert the detail shows the **term label** (not the UUID), the **localized string** (not JSON), fields appear under **group subheadings** in definition order, an empty core field shows "—", and an **Edit** link/button points to `/objects/:id/edit`. Run → fails against the current JSON.stringify rendering.

- [ ] **Step 2: Refactor `object-detail.tsx`.**
  - Update the local `Field` to take `value: React.ReactNode` and render "—" when the value is nullish/empty (instead of returning `null`) — so core fields are always shown:
```tsx
function Field({ label, value }: { label: string; value: React.ReactNode }) {
  const empty = value === null || value === undefined || value === "";
  return (
    <div className="border-b py-2">
      <div className="text-xs uppercase tracking-wide text-neutral-400">{label}</div>
      <div className="text-sm text-neutral-900">{empty ? "—" : value}</div>
    </div>
  );
}
```
  - **Header → actions toolbar:** keep `object_name` (`<h2>`) + `VisibilityBadge` on the left; move Edit + Delete into a right-aligned toolbar. Make Edit a button-styled `Link`:
```tsx
import { buttonVariants } from "@/components/ui/button";
// ...
<div className="mb-4 flex items-center gap-3">
  <h2 className="text-xl font-semibold">{object.object_name}</h2>
  <VisibilityBadge visibility={object.visibility} />
  <div className="ml-auto flex items-center gap-2">
    <Link to={`/objects/${object.id}/edit`} className={buttonVariants({ size: "sm" })}>
      {t("actions.edit")}
    </Link>
    <DeleteObjectDialog id={object.id} />
  </div>
</div>
```
  - **Core fields:** render the known core fields via `Field` (object number, count, brief description, current location, current owner, recorder, recording date). Format `recording_date` with the `formatDate` helper (import it from `flexible-field-value.tsx`, or duplicate the tiny helper — prefer exporting `formatDate` from the value module to keep one copy). They now always show (with "—").
  - **Flexible fields grouped + ordered:** replace the `Object.entries(object.fields)` block with iteration over `definitions`:
```tsx
const OTHER = t("fields.other"); // existing key used by the field list; or add objects.otherGroup
const present = (definitions ?? []).filter((d) => object.fields[d.key] != null);
const groups: { group: string; defs: FieldDefinitionView[] }[] = [];
for (const d of present) {
  const g = d.group?.trim() ? d.group : OTHER;
  const bucket = groups.find((x) => x.group === g) ?? (groups.push({ group: g, defs: [] }), groups[groups.length - 1]);
  bucket.defs.push(d);
}
// render: for each group → a subheading + each def as <Field label={labelFor(d.key)} value={<FlexibleFieldValue def={d} value={object.fields[d.key]} lang={lang} />} />
```
  Keep the existing `labelFor(key)` helper (active-locale field label). Render a group subheading (reuse the uppercase caption style). Drop the old `JSON.stringify`/`typeof` logic entirely. Keep `PublishControl` below.
  - (Confirm `fields.other` exists in i18n — the field-list screen uses it; if not, add `objects.otherGroup` to both locales.)

- [ ] **Step 3: Run tests.** `cd web && pnpm test -- object-detail flexible-field-value && pnpm typecheck && pnpm lint`. Green; the detail test now passes (labels, grouping, placeholder, Edit link).

- [ ] **Step 4: Commit** `feat(web): readable, grouped object detail (labels, placeholders, actions toolbar) (#45)`.

---

## Task 3: Final verification
- [ ] `cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size` — all green; bundle within 180 KB gz (frontend-only, ~no change).
- [ ] `pnpm test -- i18n` (en/sv parity for `common.yes`/`common.no`/`objects.unknownRef` [+ `objects.otherGroup` if added]); `git grep -in 'biggus\|dickus' -- web/src || echo CLEAN`; `git status --short` clean.
- [ ] **Manual smoke (recommended):** with the stack up + a seeded object that has a term/authority/localized field, open `/objects/:id` and confirm labels (not UUIDs/JSON), grouped sections, "—" for empty core fields, and the Edit/Delete toolbar.

---

## Self-Review (completed)
**Spec coverage:** value resolution per type + fallbacks → Task 1 (`FlexibleFieldValue` + sub-components); grouping/order + core placeholders + toolbar + date format → Task 2; story → Task 1 Step 5; tests → Task 1/2; i18n keys + parity + verification → Task 1 Step 1 / Task 3. ✓ Out of scope (export #39, form grouping, backend resolution) not included. ✓
**Placeholder scan:** concrete component + helper code given; the only "confirm X exists" notes (`fields.other`, hook `undefined` handling) are quick verifications against real code, not deferred work.
**Type consistency:** `FlexibleFieldValue({def, value, lang})` defined in Task 1, consumed in Task 2; `formatDate` exported from the value module and reused for `recording_date`; `labelText`/`useTerms`/`useAuthorities`/`buttonVariants` are existing exports.

## Notes
- No backend, no migration, no new dependency → no lockfile churn; bundle effectively unchanged.
- react-query dedups repeated `["terms", vocab]`/`["authorities", kind]` so multiple same-vocabulary term fields cause one fetch; often already warm from the table/combobox.
