feat(ui): surface title & personType fields in person forms and detail card #218

Closed
opened 2026-04-08 22:25:01 +02:00 by marcel · 12 comments
Owner

Summary

The title and personType fields exist in the backend model but are not yet editable or fully displayed in the UI. This issue adds:

  • Segmented type control (Person / Institution / Group / Unknown) at the top of edit and create forms
  • Title input (narrow, max 120px) before firstName in the name row
  • Conditional field visibility — INSTITUTION/GROUP hide title, firstName, birthYear, deathYear; lastName relabeled to "Name"/"Gruppenname"
  • PersonCard title display — small-caps label above the display name on the detail page
  • Mobile layout — type selector wraps to 2×2 grid, title + firstName share a row

Design Spec

docs/specs/person-title-type-fields-spec.html (commit 7a6b3d6)

Tasks

Backend

  • Add personType field to PersonUpdateDTO with validation (exclude SKIP from allowed values)
  • Update PersonService.update() and create() to accept personType from DTO
  • Regenerate OpenAPI spec + frontend types

Frontend — Forms

  • Add segmented type control to PersonEditForm.svelte (role="radiogroup", arrow-key nav, hidden input)
  • Add title input (narrow column) before firstName
  • Implement conditional field visibility based on selected type (see matrix in spec §4)
  • Dynamic lastName label: "Nachname" / "Name" / "Gruppenname" per type
  • Update +page.server.ts (edit + new) to read/submit title + personType
  • Mobile responsive: 2×2 type grid, title+firstName share row

Frontend — Detail Card

  • Add title display to PersonCard.svelte (small-caps, text-ink-3, above displayName, only for PERSON type)

Accessibility

  • Segmented control: role="radiogroup" / role="radio" / aria-checked / arrow keys
  • Touch targets ≥ 44px on all segment buttons
  • aria-live="polite" region announcing field visibility changes
  • Tab order: type → title → firstName → lastName → alias → birthYear → deathYear → notes

i18n

  • Add keys: form_label_title, form_label_name, form_label_group_name, form_label_person_type, person_type_PERSON, a11y_type_fields_visible (de/en/es)
## Summary The `title` and `personType` fields exist in the backend model but are not yet editable or fully displayed in the UI. This issue adds: - **Segmented type control** (Person / Institution / Group / Unknown) at the top of edit and create forms - **Title input** (narrow, max 120px) before firstName in the name row - **Conditional field visibility** — INSTITUTION/GROUP hide title, firstName, birthYear, deathYear; lastName relabeled to "Name"/"Gruppenname" - **PersonCard title display** — small-caps label above the display name on the detail page - **Mobile layout** — type selector wraps to 2×2 grid, title + firstName share a row ## Design Spec [`docs/specs/person-title-type-fields-spec.html`](https://192.168.178.71:3005/marcel/familienarchiv/src/branch/main/docs/specs/person-title-type-fields-spec.html) (commit `7a6b3d6`) ## Tasks ### Backend - [ ] Add `personType` field to `PersonUpdateDTO` with validation (exclude `SKIP` from allowed values) - [ ] Update `PersonService.update()` and `create()` to accept `personType` from DTO - [ ] Regenerate OpenAPI spec + frontend types ### Frontend — Forms - [ ] Add segmented type control to `PersonEditForm.svelte` (role="radiogroup", arrow-key nav, hidden input) - [ ] Add `title` input (narrow column) before firstName - [ ] Implement conditional field visibility based on selected type (see matrix in spec §4) - [ ] Dynamic lastName label: "Nachname" / "Name" / "Gruppenname" per type - [ ] Update `+page.server.ts` (edit + new) to read/submit `title` + `personType` - [ ] Mobile responsive: 2×2 type grid, title+firstName share row ### Frontend — Detail Card - [ ] Add `title` display to `PersonCard.svelte` (small-caps, text-ink-3, above displayName, only for PERSON type) ### Accessibility - [ ] Segmented control: `role="radiogroup"` / `role="radio"` / `aria-checked` / arrow keys - [ ] Touch targets ≥ 44px on all segment buttons - [ ] `aria-live="polite"` region announcing field visibility changes - [ ] Tab order: type → title → firstName → lastName → alias → birthYear → deathYear → notes ### i18n - [ ] Add keys: `form_label_title`, `form_label_name`, `form_label_group_name`, `form_label_person_type`, `person_type_PERSON`, `a11y_type_fields_visible` (de/en/es)
marcel added the featurepersonui labels 2026-04-08 22:25:15 +02:00
Author
Owner

🎨 Leonie Voss — UI/UX Design Lead & Accessibility Strategist

Design review of open items before implementation. Six items worked through, all resolved.


1. Title field width (120px) Resolved
The CSS grid approach in the spec is correct: grid-cols-[120px_1fr_1fr] on desktop, grid-cols-[80px_1fr] on mobile (lastName drops to full-width below). The max-w-[120px] on the input itself is slightly redundant given the grid column constraint, but harmless as a safety cap. No change needed.

2. text-ink-3 token Resolved
Fully defined in layout.css#6b7280 (gray-500, 4.8:1 on white) in light mode, #8b97a5 in dark mode. Both pass WCAG AA. Widely used across the codebase already. No new token needed.

3. Focus management on type switch Resolved
When a type switch hides the currently focused field (title, firstName, birthYear, deathYear), focus must be moved explicitly to the lastName input. Implementation note: check document.activeElement before applying the {#if} toggle; if it's inside a field about to be removed, call .focus() on the lastName input after the state update. The aria-live="polite" region handles the announcement independently.

4. Value preservation on type switch Resolved
Field values are preserved in $state across type switches. Since {#if} removes inputs from the DOM entirely, hidden values cannot be accidentally submitted. No clearing logic needed. Preserving values prevents data loss on accidental switches.

5. SKIP type fallback Resolved
personType = SKIP maps silently to UNKNOWN in the form on load. SKIP is import-only and has no user-facing meaning; mapping to UNKNOWN aligns with the spec's "hidden from UI" decision. On first save the backend will persist UNKNOWN.

6. Touch targets on mobile 2×2 grid Resolved
The spec specifies min-h-[44px] per segment button — the WCAG 2.2 minimum. Given the 60+ audience, bump this to min-h-[48px]. Verified against the mobile layout: on a 320px viewport the 2×2 grid totals ~98px (2 × 48px + 1px border), each button ~156px wide × 48px tall. Proportionate, no overflow, form is scrollable so total height is unconstrained. The spec impl-ref table (line 670) should reflect min-h-[48px] in the implementation.


Overall: the spec is well-prepared and implementation-ready. The six items above are clarifications and one small enhancement (touch targets) — no design rework required.

## 🎨 Leonie Voss — UI/UX Design Lead & Accessibility Strategist Design review of open items before implementation. Six items worked through, all resolved. --- **1. Title field width (120px)** ✅ Resolved The CSS grid approach in the spec is correct: `grid-cols-[120px_1fr_1fr]` on desktop, `grid-cols-[80px_1fr]` on mobile (lastName drops to full-width below). The `max-w-[120px]` on the input itself is slightly redundant given the grid column constraint, but harmless as a safety cap. No change needed. **2. `text-ink-3` token** ✅ Resolved Fully defined in `layout.css` — `#6b7280` (gray-500, 4.8:1 on white) in light mode, `#8b97a5` in dark mode. Both pass WCAG AA. Widely used across the codebase already. No new token needed. **3. Focus management on type switch** ✅ Resolved When a type switch hides the currently focused field (title, firstName, birthYear, deathYear), focus must be moved explicitly to the `lastName` input. Implementation note: check `document.activeElement` before applying the `{#if}` toggle; if it's inside a field about to be removed, call `.focus()` on the `lastName` input after the state update. The `aria-live="polite"` region handles the announcement independently. **4. Value preservation on type switch** ✅ Resolved Field values are preserved in `$state` across type switches. Since `{#if}` removes inputs from the DOM entirely, hidden values cannot be accidentally submitted. No clearing logic needed. Preserving values prevents data loss on accidental switches. **5. SKIP type fallback** ✅ Resolved `personType = SKIP` maps silently to UNKNOWN in the form on load. SKIP is import-only and has no user-facing meaning; mapping to UNKNOWN aligns with the spec's "hidden from UI" decision. On first save the backend will persist UNKNOWN. **6. Touch targets on mobile 2×2 grid** ✅ Resolved The spec specifies `min-h-[44px]` per segment button — the WCAG 2.2 minimum. Given the 60+ audience, bump this to **`min-h-[48px]`**. Verified against the mobile layout: on a 320px viewport the 2×2 grid totals ~98px (2 × 48px + 1px border), each button ~156px wide × 48px tall. Proportionate, no overflow, form is scrollable so total height is unconstrained. The spec impl-ref table (line 670) should reflect `min-h-[48px]` in the implementation. --- Overall: the spec is well-prepared and implementation-ready. The six items above are clarifications and one small enhancement (touch targets) — no design rework required.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Implementation review — six decisions nailed down before coding starts.


1. Segmented control — extract PersonTypeSelector.svelte Resolved
Too much responsibility to keep inline: keyboard nav, aria attributes, active state, hidden input, responsive layout. Extracted to PersonTypeSelector.svelte. Props: value: PersonType, onchange callback. Hidden <input type="hidden" name="personType"> lives inside it — form gets the value on submit without extra wiring in the parent.

2. PersonUpdateDTO validation for SKIP Resolved
Both layers: @Schema(allowableValues = {"PERSON", "INSTITUTION", "GROUP", "UNKNOWN"}) on the DTO field for OpenAPI spec documentation, plus a service-level guard clause throwing DomainException.badRequest for runtime enforcement. New INVALID_PERSON_TYPE error code needed in ErrorCode.java, mirrored in errors.ts.

3. Reactive i18n label for lastName Resolved
$derived with an intent-revealing name in the script — logic out of the template:

const lastNameLabel = $derived(
  selectedType === 'INSTITUTION' ? m.form_label_name() :
  selectedType === 'GROUP'       ? m.form_label_group_name() :
                                   m.form_label_last_name()
);

Template reads {lastNameLabel}. Same pattern for placeholder if spec requires it.

4. Arrow-key navigation Resolved
Extract use:radioGroupNav action to src/lib/actions/radioGroupNav.ts, following the use:clickOutside pattern. Handles arrow left/right, wrap-around, focus, and aria-checked updates. PersonTypeSelector.svelte applies it to the container. Needs its own test file alongside clickOutside.test.ts.

5. SKIP → UNKNOWN mapping location Resolved
Normalized in +page.server.ts load function — component never sees SKIP. Consistent with the spec's "hidden from UI" decision and keeps the component ignorant of import-only backend values.

6. Test strategy Resolved
Three layers covered now:

  • Backend: service guard rejects SKIP (unit test)
  • Frontend server: +page.server.ts load maps SKIP → UNKNOWN
  • Action: radioGroupNav arrow keys and wrap-around (mirrors clickOutside test coverage)

Component keyboard nav (aria-checked state, focus within PersonTypeSelector) deferred to E2E.


Spec is implementation-ready. The two things that need creating before any component work: INVALID_PERSON_TYPE error code and the radioGroupNav action with its test.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer Implementation review — six decisions nailed down before coding starts. --- **1. Segmented control — extract `PersonTypeSelector.svelte`** ✅ Resolved Too much responsibility to keep inline: keyboard nav, aria attributes, active state, hidden input, responsive layout. Extracted to `PersonTypeSelector.svelte`. Props: `value: PersonType`, `onchange` callback. Hidden `<input type="hidden" name="personType">` lives inside it — form gets the value on submit without extra wiring in the parent. **2. `PersonUpdateDTO` validation for SKIP** ✅ Resolved Both layers: `@Schema(allowableValues = {"PERSON", "INSTITUTION", "GROUP", "UNKNOWN"})` on the DTO field for OpenAPI spec documentation, plus a service-level guard clause throwing `DomainException.badRequest` for runtime enforcement. New `INVALID_PERSON_TYPE` error code needed in `ErrorCode.java`, mirrored in `errors.ts`. **3. Reactive i18n label for `lastName`** ✅ Resolved `$derived` with an intent-revealing name in the script — logic out of the template: ```svelte const lastNameLabel = $derived( selectedType === 'INSTITUTION' ? m.form_label_name() : selectedType === 'GROUP' ? m.form_label_group_name() : m.form_label_last_name() ); ``` Template reads `{lastNameLabel}`. Same pattern for placeholder if spec requires it. **4. Arrow-key navigation** ✅ Resolved Extract `use:radioGroupNav` action to `src/lib/actions/radioGroupNav.ts`, following the `use:clickOutside` pattern. Handles arrow left/right, wrap-around, focus, and `aria-checked` updates. `PersonTypeSelector.svelte` applies it to the container. Needs its own test file alongside `clickOutside.test.ts`. **5. SKIP → UNKNOWN mapping location** ✅ Resolved Normalized in `+page.server.ts` load function — component never sees `SKIP`. Consistent with the spec's "hidden from UI" decision and keeps the component ignorant of import-only backend values. **6. Test strategy** ✅ Resolved Three layers covered now: - Backend: service guard rejects `SKIP` (unit test) - Frontend server: `+page.server.ts` load maps SKIP → UNKNOWN - Action: `radioGroupNav` arrow keys and wrap-around (mirrors `clickOutside` test coverage) Component keyboard nav (aria-checked state, focus within `PersonTypeSelector`) deferred to E2E. --- Spec is implementation-ready. The two things that need creating before any component work: `INVALID_PERSON_TYPE` error code and the `radioGroupNav` action with its test.
Author
Owner

📋 Elicit — Requirements Review

Requirements analysis of this issue against spec §4, V22 migration, and PersonService.java. Seven open questions resolved; findings below.


Corrected Conditional Field Matrix

The issue body omits the UNKNOWN column and the alias row. Full matrix from spec §4:

Field PERSON INSTITUTION GROUP UNKNOWN
title
firstName
lastName label "Nachname" "Name" "Gruppenname" "Name"
alias
birthYear
deathYear
notes

Additions to Backend Task List

Two items missing from the current task list:

  1. @Size(max = 50) on PersonUpdateDTO.title — DB column is VARCHAR(50) (V22 migration line 2). The frontend input must also have maxlength="50". Neither is currently listed.

  2. person.setPersonType(dto.getPersonType()) is absent from updatePerson() — the method currently accepts a PersonUpdateDTO but never calls the setter. The type change is silently dropped on every save. The issue's backend task already names this fix; this confirms it is truly missing, not just undocumented.

    Design decision (explicit): firstName, title, birthYear, deathYear are not cleared server-side when type changes. Values are preserved in the DB for round-trip safety — if the user switches back to PERSON, their data returns. This aligns with Felix's comment on value preservation and must be stated as deliberate, not an oversight.


User Stories

US-PERSON-001
As a transcriber, I want to classify each person as Person / Institution / Group / Unknown, so that entity types are distinguishable in the archive and display names render correctly.

US-PERSON-002
As a transcriber, I want to enter a title (e.g. "Dr.", "Gräfin") separately from the first name, so that historical forms of address are preserved and searchable.

US-PERSON-003
As a reader, I want to see a person's title displayed on their detail card, so that the historical form of address is immediately visible.


Acceptance Criteria

US-PERSON-001 — Segmented Type Control

AC-001: Default type on new-person form
Given: the new-person form is opened
When: the page loads
Then: the PERSON segment is active.

AC-002: Field visibility — INSTITUTION
Given: the edit form is open with personType = PERSON
When: the user selects the INSTITUTION segment
Then: title, firstName, birthYear, and deathYear inputs disappear from the DOM,
  AND alias remains visible,
  AND the lastName label reads "Name".

AC-003: Field visibility — GROUP
Given: the edit form is open with personType = PERSON
When: the user selects the GROUP segment
Then: title, firstName, birthYear, and deathYear inputs disappear from the DOM,
  AND alias remains visible,
  AND the lastName label reads "Gruppenname".

AC-004: Field visibility — UNKNOWN
Given: the edit form is open with personType = PERSON
When: the user selects the UNKNOWN segment
Then: title, firstName, alias, birthYear, and deathYear inputs disappear from the DOM,
  AND notes remains visible,
  AND the lastName label reads "Name".

AC-005: Value preservation on type round-trip
Given: the edit form has firstName = "Anna" and birthYear = 1905 with personType = PERSON
When: the user switches to INSTITUTION, then back to PERSON
Then: the firstName input shows "Anna" and birthYear shows "1905".

AC-006: SKIP → UNKNOWN mapping on load
Given: an existing person record has personType = SKIP (assigned by import)
When: the edit form loads
Then: the UNKNOWN segment is active,
  AND saving the form persists UNKNOWN to the backend.

AC-007: Focus management on type switch
Given: focus is on the firstName input
When: the user switches to a type where firstName is hidden
Then: focus moves to the lastName input before the DOM update completes.

AC-008: personType persisted on save
Given: the edit form has INSTITUTION selected
When: the user clicks Save
Then: the backend persists personType = INSTITUTION,
  AND reloading the edit form shows INSTITUTION as the active segment.

AC-009: Arrow-key navigation
Given: focus is on any segment button
When: the user presses ArrowRight
Then: focus moves to the next segment (wrapping from last to first),
  AND aria-checked updates to reflect the focused segment.
When: the user presses ArrowLeft
Then: focus moves to the previous segment (wrapping from first to last).

AC-010: Touch target size (mobile)
Given: the form is rendered on a viewport ≤ 768px (2×2 grid layout)
When: the type selector renders
Then: each segment button has min-height 48px and min-width 44px.

US-PERSON-002 — Title Field

AC-011: Title visible only for PERSON type
Given: the edit form is open with personType = PERSON
When: the page loads
Then: the title input is present in the DOM, immediately before firstName.

AC-012: Title absent for non-PERSON types
Given: the edit form is open
When: the user selects INSTITUTION, GROUP, or UNKNOWN
Then: the title input is not present in the DOM
  AND cannot be submitted.

AC-013: Title maxlength
Given: the title input is visible
When: the user types more than 50 characters
Then: the input is capped at 50 characters (maxlength="50"),
  AND no server-side validation error is triggered.

AC-014: Title submitted and persisted
Given: title = "Dr." is entered with personType = PERSON
When: the user clicks Save
Then: the backend persists title = "Dr.",
  AND reloading the edit form shows "Dr." in the title input.

US-PERSON-003 — PersonCard Title Display

AC-015: Title displayed above name (PERSON type only)
Given: a person record has personType = PERSON and title = "Dr."
When: the PersonCard detail page loads
Then: "Dr." is rendered above the display name in small-caps style (text-ink-3).

AC-016: Title absent for non-PERSON types
Given: a person record has personType = INSTITUTION
When: the PersonCard detail page loads
Then: no title element is rendered.

AC-017: No title element when title is blank
Given: a PERSON record has no title set
When: the PersonCard detail page loads
Then: no title element is rendered (no empty space above the name).

EARS System Rules

REQ-PERSON-001: When a PersonUpdateDTO with personType = SKIP is submitted,
  the system shall reject the request with error code INVALID_PERSON_TYPE.

REQ-PERSON-002: When personType is updated, the system shall persist the new
  type and shall not clear firstName, title, birthYear, or deathYear —
  these values are preserved for round-trip safety.

REQ-PERSON-003: The system shall enforce title ≤ 50 characters at both the
  DTO validation layer (@Size(max = 50)) and the frontend input (maxlength="50").

Non-Goal (explicit)

Auto-detection of personType based on form input is out of scope. Classification via PersonTypeClassifier exists for mass import only. The UI is always manual.

## 📋 Elicit — Requirements Review Requirements analysis of this issue against spec §4, V22 migration, and `PersonService.java`. Seven open questions resolved; findings below. --- ### Corrected Conditional Field Matrix The issue body omits the UNKNOWN column and the alias row. Full matrix from spec §4: | Field | PERSON | INSTITUTION | GROUP | UNKNOWN | |---|---|---|---|---| | title | ✅ | ❌ | ❌ | ❌ | | firstName | ✅ | ❌ | ❌ | ❌ | | lastName label | "Nachname" | "Name" | "Gruppenname" | "Name" | | alias | ✅ | ✅ | ✅ | ❌ | | birthYear | ✅ | ❌ | ❌ | ❌ | | deathYear | ✅ | ❌ | ❌ | ❌ | | notes | ✅ | ✅ | ✅ | ✅ | --- ### Additions to Backend Task List Two items missing from the current task list: 1. **`@Size(max = 50)` on `PersonUpdateDTO.title`** — DB column is `VARCHAR(50)` (V22 migration line 2). The frontend input must also have `maxlength="50"`. Neither is currently listed. 2. **`person.setPersonType(dto.getPersonType())` is absent from `updatePerson()`** — the method currently accepts a `PersonUpdateDTO` but never calls the setter. The type change is silently dropped on every save. The issue's backend task already names this fix; this confirms it is truly missing, not just undocumented. **Design decision (explicit):** `firstName`, `title`, `birthYear`, `deathYear` are **not cleared** server-side when type changes. Values are preserved in the DB for round-trip safety — if the user switches back to PERSON, their data returns. This aligns with Felix's comment on value preservation and must be stated as deliberate, not an oversight. --- ### User Stories **US-PERSON-001** As a **transcriber**, I want to classify each person as Person / Institution / Group / Unknown, so that entity types are distinguishable in the archive and display names render correctly. **US-PERSON-002** As a **transcriber**, I want to enter a title (e.g. "Dr.", "Gräfin") separately from the first name, so that historical forms of address are preserved and searchable. **US-PERSON-003** As a **reader**, I want to see a person's title displayed on their detail card, so that the historical form of address is immediately visible. --- ### Acceptance Criteria #### US-PERSON-001 — Segmented Type Control ``` AC-001: Default type on new-person form Given: the new-person form is opened When: the page loads Then: the PERSON segment is active. AC-002: Field visibility — INSTITUTION Given: the edit form is open with personType = PERSON When: the user selects the INSTITUTION segment Then: title, firstName, birthYear, and deathYear inputs disappear from the DOM, AND alias remains visible, AND the lastName label reads "Name". AC-003: Field visibility — GROUP Given: the edit form is open with personType = PERSON When: the user selects the GROUP segment Then: title, firstName, birthYear, and deathYear inputs disappear from the DOM, AND alias remains visible, AND the lastName label reads "Gruppenname". AC-004: Field visibility — UNKNOWN Given: the edit form is open with personType = PERSON When: the user selects the UNKNOWN segment Then: title, firstName, alias, birthYear, and deathYear inputs disappear from the DOM, AND notes remains visible, AND the lastName label reads "Name". AC-005: Value preservation on type round-trip Given: the edit form has firstName = "Anna" and birthYear = 1905 with personType = PERSON When: the user switches to INSTITUTION, then back to PERSON Then: the firstName input shows "Anna" and birthYear shows "1905". AC-006: SKIP → UNKNOWN mapping on load Given: an existing person record has personType = SKIP (assigned by import) When: the edit form loads Then: the UNKNOWN segment is active, AND saving the form persists UNKNOWN to the backend. AC-007: Focus management on type switch Given: focus is on the firstName input When: the user switches to a type where firstName is hidden Then: focus moves to the lastName input before the DOM update completes. AC-008: personType persisted on save Given: the edit form has INSTITUTION selected When: the user clicks Save Then: the backend persists personType = INSTITUTION, AND reloading the edit form shows INSTITUTION as the active segment. AC-009: Arrow-key navigation Given: focus is on any segment button When: the user presses ArrowRight Then: focus moves to the next segment (wrapping from last to first), AND aria-checked updates to reflect the focused segment. When: the user presses ArrowLeft Then: focus moves to the previous segment (wrapping from first to last). AC-010: Touch target size (mobile) Given: the form is rendered on a viewport ≤ 768px (2×2 grid layout) When: the type selector renders Then: each segment button has min-height 48px and min-width 44px. ``` #### US-PERSON-002 — Title Field ``` AC-011: Title visible only for PERSON type Given: the edit form is open with personType = PERSON When: the page loads Then: the title input is present in the DOM, immediately before firstName. AC-012: Title absent for non-PERSON types Given: the edit form is open When: the user selects INSTITUTION, GROUP, or UNKNOWN Then: the title input is not present in the DOM AND cannot be submitted. AC-013: Title maxlength Given: the title input is visible When: the user types more than 50 characters Then: the input is capped at 50 characters (maxlength="50"), AND no server-side validation error is triggered. AC-014: Title submitted and persisted Given: title = "Dr." is entered with personType = PERSON When: the user clicks Save Then: the backend persists title = "Dr.", AND reloading the edit form shows "Dr." in the title input. ``` #### US-PERSON-003 — PersonCard Title Display ``` AC-015: Title displayed above name (PERSON type only) Given: a person record has personType = PERSON and title = "Dr." When: the PersonCard detail page loads Then: "Dr." is rendered above the display name in small-caps style (text-ink-3). AC-016: Title absent for non-PERSON types Given: a person record has personType = INSTITUTION When: the PersonCard detail page loads Then: no title element is rendered. AC-017: No title element when title is blank Given: a PERSON record has no title set When: the PersonCard detail page loads Then: no title element is rendered (no empty space above the name). ``` --- ### EARS System Rules ``` REQ-PERSON-001: When a PersonUpdateDTO with personType = SKIP is submitted, the system shall reject the request with error code INVALID_PERSON_TYPE. REQ-PERSON-002: When personType is updated, the system shall persist the new type and shall not clear firstName, title, birthYear, or deathYear — these values are preserved for round-trip safety. REQ-PERSON-003: The system shall enforce title ≤ 50 characters at both the DTO validation layer (@Size(max = 50)) and the frontend input (maxlength="50"). ``` --- ### Non-Goal (explicit) Auto-detection of `personType` based on form input is out of scope. Classification via `PersonTypeClassifier` exists for mass import only. The UI is always manual.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Six decisions from the 2026-04-15 pre-review stand. This pass focuses on what codebase research turned up.

Observations

Four concrete implementation gaps confirmed in the code:

  1. PersonUpdateDTO has no personType fieldtitle is present, personType is not. The backend task is correct. Recommendation: declare it as PersonType enum (not String) so Jackson rejects invalid values at deserialization — no extra validation layer needed for garbage input.

  2. PersonService.updatePerson() and createPerson(PersonUpdateDTO) never call person.setPersonType() — confirmed at lines 111–123 and 138–150. Both methods handle title but silently ignore personType.

  3. +page.server.ts (edit route) doesn't extract title or personType — confirmed at lines 29–63. Two formData.get() calls are missing. This means both fields are lost on every save today.

  4. radioGroupNav.ts does not existclickOutside.ts is available as the pattern model. The action needs: arrow left/right, wrap-around, focus update, aria-checked update. Create it with a .test.ts before PersonTypeSelector.svelte is written (test first).

Generated type inconsistency: api.ts currently shows personType?: string — a plain string. After adding personType: PersonType to the Java DTO with @Schema, regenerate types and verify the result becomes "PERSON" | "INSTITUTION" | "GROUP" | "UNKNOWN", not string.

Test coverage gap: PersonServiceTest has zero tests for title persistence and zero for personType in create/update methods.

Recommendations

  • Declare private PersonType personType; in PersonUpdateDTO (the enum type, not String).
  • Write these failing tests before any implementation (red/green in order):
    1. should_persist_personType_when_createPerson_is_called()
    2. should_persist_personType_when_updatePerson_is_called()
    3. should_throw_INVALID_PERSON_TYPE_when_SKIP_is_submitted()
    4. radioGroupNav.test.ts — arrow cycle, wrap-around, aria-checked state
    5. should_map_SKIP_to_UNKNOWN_in_page_server_load() — import the load function directly, mock fetch
  • After DTO change: rebuild JAR → start with --spring.profiles.active=dev → run npm run generate:api before touching any frontend component.
  • PersonTypeSelector.svelte props interface: value: PersonType, onchange: (type: PersonType) => void. Hidden input lives inside the component as already decided.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer Six decisions from the 2026-04-15 pre-review stand. This pass focuses on what codebase research turned up. ### Observations **Four concrete implementation gaps confirmed in the code:** 1. **`PersonUpdateDTO` has no `personType` field** — `title` is present, `personType` is not. The backend task is correct. Recommendation: declare it as `PersonType` enum (not `String`) so Jackson rejects invalid values at deserialization — no extra validation layer needed for garbage input. 2. **`PersonService.updatePerson()` and `createPerson(PersonUpdateDTO)` never call `person.setPersonType()`** — confirmed at lines 111–123 and 138–150. Both methods handle `title` but silently ignore `personType`. 3. **`+page.server.ts` (edit route) doesn't extract `title` or `personType`** — confirmed at lines 29–63. Two `formData.get()` calls are missing. This means both fields are lost on every save today. 4. **`radioGroupNav.ts` does not exist** — `clickOutside.ts` is available as the pattern model. The action needs: arrow left/right, wrap-around, focus update, `aria-checked` update. Create it with a `.test.ts` before `PersonTypeSelector.svelte` is written (test first). **Generated type inconsistency:** `api.ts` currently shows `personType?: string` — a plain string. After adding `personType: PersonType` to the Java DTO with `@Schema`, regenerate types and verify the result becomes `"PERSON" | "INSTITUTION" | "GROUP" | "UNKNOWN"`, not `string`. **Test coverage gap:** `PersonServiceTest` has zero tests for `title` persistence and zero for `personType` in create/update methods. ### Recommendations - Declare `private PersonType personType;` in `PersonUpdateDTO` (the enum type, not String). - Write these failing tests before any implementation (red/green in order): 1. `should_persist_personType_when_createPerson_is_called()` 2. `should_persist_personType_when_updatePerson_is_called()` 3. `should_throw_INVALID_PERSON_TYPE_when_SKIP_is_submitted()` 4. `radioGroupNav.test.ts` — arrow cycle, wrap-around, aria-checked state 5. `should_map_SKIP_to_UNKNOWN_in_page_server_load()` — import the load function directly, mock fetch - After DTO change: rebuild JAR → start with `--spring.profiles.active=dev` → run `npm run generate:api` before touching any frontend component. - `PersonTypeSelector.svelte` props interface: `value: PersonType`, `onchange: (type: PersonType) => void`. Hidden input lives inside the component as already decided.
Author
Owner

🏛️ Markus Keller — Application Architect

Observations

DB layer is already sound: V22 migration has CHECK (person_type IN ('PERSON', 'INSTITUTION', 'GROUP', 'UNKNOWN')) — SKIP cannot be persisted regardless of application behavior. This is the correct design: the database enforces the invariant atomically. The service-level SKIP guard is defense-in-depth on top, not the primary protection.

No module boundary violations: Pure Person-domain change. Controller → Service → Repository path unchanged. No cross-domain dependency introduced.

Import classifier vs. user override — no conflict: findOrCreateByAlias() is find-or-create. If the person already exists it returns it without modification. A user who manually sets personType = INSTITUTION will not have it overwritten by a subsequent import of the same alias. The classifier only runs during initial creation.

DTO field type matters architecturally: If personType is declared as String, Jackson binds any string that arrives in the request body. Service validation catches SKIP but not "", "null", or "SUPERUSER". Declaring it as the PersonType enum means Jackson rejects non-enum values with a 400 at deserialization — no service code required for that class of invalid input.

Partial update semantics: personType is NOT NULL in the DB (default 'PERSON'). The DTO field should be nullable so that API clients sending a partial update (e.g. only changing notes) don't need to resend personType. The service should apply setPersonType() only when dto.getPersonType() != null.

Recommendations

  • Declare private PersonType personType; (nullable) in PersonUpdateDTO. In updatePerson(): if (dto.getPersonType() != null) person.setPersonType(dto.getPersonType());
  • The service guard rejecting SKIP is correct but document it explicitly: the DB constraint is the last line of defense; the guard is the API contract boundary.
  • No new migration needed — V22 already has the column with the constraint.

Open Decisions

  • Null personType in DTO: preserve existing vs. require explicit value — If nullable, a partial update that omits personType silently preserves the existing type. If required (non-null), the form always sends a value and the API contract is cleaner. Given the form always sends a type (hidden input with default), either works for the UI. The nullable approach is safer for any future API client. (Raised by: Markus)
## 🏛️ Markus Keller — Application Architect ### Observations **DB layer is already sound**: V22 migration has `CHECK (person_type IN ('PERSON', 'INSTITUTION', 'GROUP', 'UNKNOWN'))` — SKIP cannot be persisted regardless of application behavior. This is the correct design: the database enforces the invariant atomically. The service-level SKIP guard is defense-in-depth on top, not the primary protection. **No module boundary violations**: Pure Person-domain change. Controller → Service → Repository path unchanged. No cross-domain dependency introduced. **Import classifier vs. user override — no conflict**: `findOrCreateByAlias()` is find-or-create. If the person already exists it returns it without modification. A user who manually sets `personType = INSTITUTION` will not have it overwritten by a subsequent import of the same alias. The classifier only runs during initial creation. **DTO field type matters architecturally**: If `personType` is declared as `String`, Jackson binds any string that arrives in the request body. Service validation catches SKIP but not `""`, `"null"`, or `"SUPERUSER"`. Declaring it as the `PersonType` enum means Jackson rejects non-enum values with a 400 at deserialization — no service code required for that class of invalid input. **Partial update semantics**: `personType` is NOT NULL in the DB (default `'PERSON'`). The DTO field should be nullable so that API clients sending a partial update (e.g. only changing `notes`) don't need to resend `personType`. The service should apply `setPersonType()` only when `dto.getPersonType() != null`. ### Recommendations - Declare `private PersonType personType;` (nullable) in `PersonUpdateDTO`. In `updatePerson()`: `if (dto.getPersonType() != null) person.setPersonType(dto.getPersonType());` - The service guard rejecting SKIP is correct but document it explicitly: the DB constraint is the last line of defense; the guard is the API contract boundary. - No new migration needed — V22 already has the column with the constraint. ### Open Decisions - **Null `personType` in DTO: preserve existing vs. require explicit value** — If nullable, a partial update that omits `personType` silently preserves the existing type. If required (non-null), the form always sends a value and the API contract is cleaner. Given the form always sends a type (hidden input with default), either works for the UI. The nullable approach is safer for any future API client. _(Raised by: Markus)_
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Observations

Permissions are correct: Both POST /api/persons and PUT /api/persons/{id} require @RequirePermission(Permission.WRITE_ALL). Adding personType as a writeable field adds no new permission surface — it's behind the same gate that already protects firstName, lastName, etc.

Mass assignment risk — personType as String: The prior review (Felix's comment) described adding @Schema(allowableValues = {"PERSON", "INSTITUTION", "GROUP", "UNKNOWN"}) on a String field. @Schema is OpenAPI documentation only — it does not reject invalid strings at runtime. Jackson will happily bind "SKIP", "ADMIN", or "'; DROP TABLE persons;--" to a String field and pass it to the service. The service guard only catches SKIP; everything else passes unchecked into person.setPersonType() (once that setter is added).

The correct fix: declare personType as the PersonType enum type in the DTO. Jackson's enum deserialization rejects any value not in the enum with a 400 automatically. SKIP is a valid enum value (it exists in Java), so the service guard for SKIP is still required. Two independent layers: Jackson blocks non-enum garbage; the service guard blocks the SKIP enum value.

@Size(max = 50) on PersonUpdateDTO.title: The DTO currently has title listed but the @Size annotation presence is unconfirmed. Without it, a 51-character title passes backend validation, reaches PostgreSQL, and throws a constraint violation — which the global error handler may or may not surface cleanly. Add @Size(max = 50) explicitly rather than relying on the DB constraint to be the first line.

INVALID_PERSON_TYPE error code: This is a service-layer domain error. It must be added to ErrorCode.java, mirrored in errors.ts, and translated in all three i18n files. Without this, the service throws a DomainException with an unmapped code and the frontend displays a generic error message.

No new injection surfaces: personType and title flow through parameterized JPA. No SQL injection risk introduced.

Recommendations

  • Declare private PersonType personType; (the Java enum) in PersonUpdateDTO. Do not use String.
  • Verify @Size(max = 50) is on PersonUpdateDTO.title. If missing, add it now — don't rely on the DB constraint as the first line of defense.
  • Write the SKIP rejection test before the service guard clause (red/green). The test proves the guard is needed. Without the test, a future refactor could silently remove the guard.
  • Add INVALID_PERSON_TYPE to ErrorCode.java + errors.ts + {de,en,es}.json as part of this issue.
## 🔒 Nora "NullX" Steiner — Application Security Engineer ### Observations **Permissions are correct**: Both `POST /api/persons` and `PUT /api/persons/{id}` require `@RequirePermission(Permission.WRITE_ALL)`. Adding `personType` as a writeable field adds no new permission surface — it's behind the same gate that already protects firstName, lastName, etc. **Mass assignment risk — `personType` as String**: The prior review (Felix's comment) described adding `@Schema(allowableValues = {"PERSON", "INSTITUTION", "GROUP", "UNKNOWN"})` on a String field. `@Schema` is OpenAPI documentation only — it does **not** reject invalid strings at runtime. Jackson will happily bind `"SKIP"`, `"ADMIN"`, or `"'; DROP TABLE persons;--"` to a String field and pass it to the service. The service guard only catches SKIP; everything else passes unchecked into `person.setPersonType()` (once that setter is added). The correct fix: declare `personType` as the `PersonType` enum type in the DTO. Jackson's enum deserialization rejects any value not in the enum with a 400 automatically. SKIP is a valid enum value (it exists in Java), so the service guard for SKIP is still required. Two independent layers: Jackson blocks non-enum garbage; the service guard blocks the SKIP enum value. **`@Size(max = 50)` on `PersonUpdateDTO.title`**: The DTO currently has `title` listed but the `@Size` annotation presence is unconfirmed. Without it, a 51-character title passes backend validation, reaches PostgreSQL, and throws a constraint violation — which the global error handler may or may not surface cleanly. Add `@Size(max = 50)` explicitly rather than relying on the DB constraint to be the first line. **`INVALID_PERSON_TYPE` error code**: This is a service-layer domain error. It must be added to `ErrorCode.java`, mirrored in `errors.ts`, and translated in all three i18n files. Without this, the service throws a `DomainException` with an unmapped code and the frontend displays a generic error message. **No new injection surfaces**: `personType` and `title` flow through parameterized JPA. No SQL injection risk introduced. ### Recommendations - Declare `private PersonType personType;` (the Java enum) in `PersonUpdateDTO`. Do not use `String`. - Verify `@Size(max = 50)` is on `PersonUpdateDTO.title`. If missing, add it now — don't rely on the DB constraint as the first line of defense. - Write the SKIP rejection test **before** the service guard clause (red/green). The test proves the guard is needed. Without the test, a future refactor could silently remove the guard. - Add `INVALID_PERSON_TYPE` to `ErrorCode.java` + `errors.ts` + `{de,en,es}.json` as part of this issue.
Author
Owner

🧪 Sara Holt — QA Engineer

Observations

Confirmed coverage gaps (codebase-verified):

  • PersonServiceTest: zero tests for title persistence in create/update; zero for personType persistence; zero for SKIP rejection
  • radioGroupNav.ts: doesn't exist, so no tests
  • +page.server.ts load function: SKIP → UNKNOWN mapping is completely untested

Full test plan for this feature:

Layer Test File
Backend unit should_persist_personType_when_createPerson_is_called PersonServiceTest
Backend unit should_persist_personType_when_updatePerson_is_called PersonServiceTest
Backend unit should_throw_INVALID_PERSON_TYPE_when_SKIP_is_submitted PersonServiceTest
Backend unit should_preserve_personType_when_dto_personType_is_null PersonServiceTest
Backend unit should_persist_title_when_createPerson_is_called PersonServiceTest
Frontend unit Arrow cycle, wrap-around, aria-checked update radioGroupNav.test.ts
Frontend load SKIP → UNKNOWN in edit form load +page.server.ts unit test
Frontend component Active segment renders, hidden input has correct value PersonTypeSelector.spec.ts
Frontend component Field visibility changes on type switch PersonEditForm.spec.ts
E2E Create INSTITUTION — no firstName field, persisted correctly Playwright
E2E Edit PERSON → switch GROUP → save → reload shows GROUP Playwright
E2E Keyboard navigation through type selector Playwright
E2E AxeBuilder on edit form with PERSON type active Playwright
E2E AxeBuilder on edit form with INSTITUTION type active Playwright

Two axe runs needed: PERSON and INSTITUTION modes have different DOM structures (different fields present/absent, different aria tree). Running axe only on PERSON state misses the INSTITUTION layout.

AC-005 (value preservation) and AC-007 (focus management) are Playwright-only — no unit test equivalent. Both are testable with page.keyboard.press('Tab') and field value assertions after type switch.

AC-013 (title maxlength): test at both layers — @Size violation test in PersonServiceTest with a 51-character title, and a frontend attribute test asserting maxlength="50" on the input element.

Recommendations

  • Write tests in the order listed above. Backend tests first — the DTO change triggers type regeneration, which unblocks all frontend tests.
  • radioGroupNav.test.ts should mirror clickOutside.test.ts in structure (same folder, same test runner setup).
  • Don't defer the +page.server.ts load function test — it's a plain TypeScript import with a mocked fetch, takes 5 minutes to write, and is the only coverage for the SKIP → UNKNOWN mapping.
## 🧪 Sara Holt — QA Engineer ### Observations **Confirmed coverage gaps** (codebase-verified): - `PersonServiceTest`: zero tests for `title` persistence in create/update; zero for `personType` persistence; zero for SKIP rejection - `radioGroupNav.ts`: doesn't exist, so no tests - `+page.server.ts` load function: SKIP → UNKNOWN mapping is completely untested **Full test plan for this feature:** | Layer | Test | File | |---|---|---| | Backend unit | `should_persist_personType_when_createPerson_is_called` | `PersonServiceTest` | | Backend unit | `should_persist_personType_when_updatePerson_is_called` | `PersonServiceTest` | | Backend unit | `should_throw_INVALID_PERSON_TYPE_when_SKIP_is_submitted` | `PersonServiceTest` | | Backend unit | `should_preserve_personType_when_dto_personType_is_null` | `PersonServiceTest` | | Backend unit | `should_persist_title_when_createPerson_is_called` | `PersonServiceTest` | | Frontend unit | Arrow cycle, wrap-around, `aria-checked` update | `radioGroupNav.test.ts` | | Frontend load | SKIP → UNKNOWN in edit form load | `+page.server.ts` unit test | | Frontend component | Active segment renders, hidden input has correct value | `PersonTypeSelector.spec.ts` | | Frontend component | Field visibility changes on type switch | `PersonEditForm.spec.ts` | | E2E | Create INSTITUTION — no firstName field, persisted correctly | Playwright | | E2E | Edit PERSON → switch GROUP → save → reload shows GROUP | Playwright | | E2E | Keyboard navigation through type selector | Playwright | | E2E | `AxeBuilder` on edit form with PERSON type active | Playwright | | E2E | `AxeBuilder` on edit form with INSTITUTION type active | Playwright | **Two axe runs needed**: PERSON and INSTITUTION modes have different DOM structures (different fields present/absent, different aria tree). Running axe only on PERSON state misses the INSTITUTION layout. **AC-005 (value preservation) and AC-007 (focus management) are Playwright-only** — no unit test equivalent. Both are testable with `page.keyboard.press('Tab')` and field value assertions after type switch. **AC-013 (title maxlength)**: test at both layers — `@Size` violation test in `PersonServiceTest` with a 51-character title, and a frontend attribute test asserting `maxlength="50"` on the input element. ### Recommendations - Write tests in the order listed above. Backend tests first — the DTO change triggers type regeneration, which unblocks all frontend tests. - `radioGroupNav.test.ts` should mirror `clickOutside.test.ts` in structure (same folder, same test runner setup). - Don't defer the `+page.server.ts` load function test — it's a plain TypeScript import with a mocked `fetch`, takes 5 minutes to write, and is the only coverage for the SKIP → UNKNOWN mapping.
Author
Owner

🎨 Leonie Voss — UX Design Lead & Accessibility Strategist

Pre-implementation design decisions are resolved (see 2026-04-15 comment). Three items for this implementation pass.

Observations

1. Spec impl-ref table still says min-h-[44px] — must be min-h-[48px]

My prior comment upgraded the mobile touch target to 48px. The spec implementation reference table (§2, type selector mobile row) still reads min-h-[44px]. The developer implements from the table, not from the comment thread. If this isn't corrected in the spec, the 48px requirement will be missed. This is Critical — the 60+ audience is the primary transcriber demographic.

2. aria-live copy needs to cover both directions

The issue lists i18n key a11y_type_fields_visible. This name implies fields appearing, but the live region fires on both hide and show. A key named "visible" makes no sense when fields disappear. Recommendation: replace with a single type-name template key — a11y_type_changed — used as: a11y_type_changed({ type: m[personType]() }), resolving to e.g. "Ansicht: Person" or "Ansicht: Institution". One key, always describes the new active state, works for both directions.

3. Title input label association — use <label> not aria-label

The spec impl-ref specifies aria-label = i18n key for "Akademischer Titel". If the input already has a visible <label for="title-input"> element (which it should per the form pattern), aria-label is redundant and conflicts — the visible label and the programmatic label would be different text. Use <label for="title-input"> + <input id="title-input"> pairing. This is simpler, uses one i18n key (form_label_title which is already listed), and is the correct semantic pattern. No extra i18n key needed.

Recommendations

  • Update the spec impl-ref table §2 mobile row to min-h-[48px] before implementation starts.
  • Replace a11y_type_fields_visible i18n key with a11y_type_changed accepting a type parameter. Add translations for all three locales.
  • Use <label for> / id pairing for the title input instead of aria-label. Remove the aria-label reference from the impl-ref table.
## 🎨 Leonie Voss — UX Design Lead & Accessibility Strategist Pre-implementation design decisions are resolved (see 2026-04-15 comment). Three items for this implementation pass. ### Observations **1. Spec impl-ref table still says `min-h-[44px]` — must be `min-h-[48px]`** My prior comment upgraded the mobile touch target to 48px. The spec implementation reference table (§2, type selector mobile row) still reads `min-h-[44px]`. The developer implements from the table, not from the comment thread. If this isn't corrected in the spec, the 48px requirement will be missed. This is Critical — the 60+ audience is the primary transcriber demographic. **2. `aria-live` copy needs to cover both directions** The issue lists i18n key `a11y_type_fields_visible`. This name implies fields appearing, but the live region fires on both hide and show. A key named "visible" makes no sense when fields disappear. Recommendation: replace with a single type-name template key — `a11y_type_changed` — used as: `a11y_type_changed({ type: m[personType]() })`, resolving to e.g. "Ansicht: Person" or "Ansicht: Institution". One key, always describes the new active state, works for both directions. **3. Title input label association — use `<label>` not `aria-label`** The spec impl-ref specifies `aria-label` = i18n key for "Akademischer Titel". If the input already has a visible `<label for="title-input">` element (which it should per the form pattern), `aria-label` is redundant and conflicts — the visible label and the programmatic label would be different text. Use `<label for="title-input">` + `<input id="title-input">` pairing. This is simpler, uses one i18n key (`form_label_title` which is already listed), and is the correct semantic pattern. No extra i18n key needed. ### Recommendations - Update the spec impl-ref table §2 mobile row to `min-h-[48px]` before implementation starts. - Replace `a11y_type_fields_visible` i18n key with `a11y_type_changed` accepting a `type` parameter. Add translations for all three locales. - Use `<label for>` / `id` pairing for the title input instead of `aria-label`. Remove the `aria-label` reference from the impl-ref table.
Author
Owner

⚙️ Tobias Wendt — DevOps & Platform Engineer

Observations

No infrastructure changes needed: V22 migration already adds title and person_type columns. No new Docker services, environment variables, MinIO buckets, or CI pipeline jobs are required for this feature.

Type regeneration is a manual step with a silent failure mode: After adding personType to PersonUpdateDTO and rebuilding the backend, npm run generate:api must be run manually before frontend work. The current api.ts already has personType?: string in the DTO schema — this means the TypeScript compiler won't catch the pre/post-change difference, and a developer who forgets to regenerate will not see a build error. The drift will be invisible until runtime.

CI does not run type generation automatically: There is no step in the CI workflow that regenerates the OpenAPI types on backend changes and fails if the generated file has uncommitted diffs. This is an accepted limitation for a solo project.

Recommendations

  • No action needed for this issue from an infra perspective.
  • Add to the issue task list: [ ] Rebuild JAR → start with --spring.profiles.active=dev → run npm run generate:api before starting frontend tasks. It's the current convention; making it explicit in the checklist prevents a silent type mismatch.
  • Longer-term (not blocking this issue): add a CI check that runs npm run generate:api and fails if api.ts has a diff. Cost: ~30 seconds of CI time. Benefit: catches every future DTO change that wasn't regenerated. Worth opening a separate issue for.
## ⚙️ Tobias Wendt — DevOps & Platform Engineer ### Observations **No infrastructure changes needed**: V22 migration already adds `title` and `person_type` columns. No new Docker services, environment variables, MinIO buckets, or CI pipeline jobs are required for this feature. **Type regeneration is a manual step with a silent failure mode**: After adding `personType` to `PersonUpdateDTO` and rebuilding the backend, `npm run generate:api` must be run manually before frontend work. The current `api.ts` already has `personType?: string` in the DTO schema — this means the TypeScript compiler won't catch the pre/post-change difference, and a developer who forgets to regenerate will not see a build error. The drift will be invisible until runtime. **CI does not run type generation automatically**: There is no step in the CI workflow that regenerates the OpenAPI types on backend changes and fails if the generated file has uncommitted diffs. This is an accepted limitation for a solo project. ### Recommendations - No action needed for this issue from an infra perspective. - Add to the issue task list: `[ ] Rebuild JAR → start with --spring.profiles.active=dev → run npm run generate:api before starting frontend tasks.` It's the current convention; making it explicit in the checklist prevents a silent type mismatch. - Longer-term (not blocking this issue): add a CI check that runs `npm run generate:api` and fails if `api.ts` has a diff. Cost: ~30 seconds of CI time. Benefit: catches every future DTO change that wasn't regenerated. Worth opening a separate issue for.
Author
Owner

🗳️ Decision Queue — Action Required

1 decision needs your input before implementation starts.

Backend API Design

  • Null personType in PersonUpdateDTO: preserve existing type vs. require explicit value — If nullable: a partial update that omits the field silently keeps the existing type (safe for future API clients, consistent with how all other optional DTO fields work). If required (non-null): the API contract is clearer and no null-guard needed in the service. In practice the form always sends a value (hidden input with default), so either works for the UI. Lean nullable unless you want to enforce that callers always declare intent. (Raised by: Markus)
## 🗳️ Decision Queue — Action Required _1 decision needs your input before implementation starts._ ### Backend API Design - **Null `personType` in `PersonUpdateDTO`: preserve existing type vs. require explicit value** — If nullable: a partial update that omits the field silently keeps the existing type (safe for future API clients, consistent with how all other optional DTO fields work). If required (non-null): the API contract is clearer and no null-guard needed in the service. In practice the form always sends a value (hidden input with default), so either works for the UI. Lean nullable unless you want to enforce that callers always declare intent. _(Raised by: Markus)_
Author
Owner

It's non-nullable, PersonType is always required

It's non-nullable, PersonType is always required
Author
Owner

Implementierung abgeschlossen

Alle 16 Tasks wurden umgesetzt. Branch: feat/issue-218-person-title-type-fields

Backend (15 neue Tests, alle grün — 1353 total)

  • aac8250aINVALID_PERSON_TYPE ErrorCode + i18n-Übersetzungen (de/en/es)
  • 6c117611personType (@NotNull) + title (@Size max 50) in PersonUpdateDTO; createPerson persistiert personType
  • fef021bfcreatePerson wirft INVALID_PERSON_TYPE bei SKIP
  • 39f722feupdatePerson persistiert personType
  • 60a278adupdatePerson wirft INVALID_PERSON_TYPE bei SKIP
  • 58b3dabePersonController.validatePersonNames(): Vorname nur für Typ PERSON Pflichtfeld; Institution/Gruppe/Unbekannt erlaubt keinen Vornamen

Frontend (23 neue Server-Tests, alle grün — 377 total)

  • bf313801radioGroupNav.ts Svelte-Action: ArrowLeft/Right-Navigation, Wrap-around, aria-checked-Updates; 7 Browser-Tests
  • e7573bbe — SKIP→UNKNOWN-Normalisierung im load der Edit-Route; 6 Unit-Tests
  • 8f755525 — i18n-Keys: form_label_person_type, form_label_name, a11y_type_changed (mit {type}-Param) in de/en/es
  • fe830ad6PersonTypeSelector.svelte: 4-Segment-Steuerlement (PERSON/INSTITUTION/GRUPPE/UNBEKANNT), role="radiogroup", use:radioGroupNav, 2×2-Mobil-Grid, min-h-[48px] Touch-Targets, aria-live="polite"-Ankündigung
  • 17863869PersonEditForm.svelte neu: Typ-Selektor oben, Titel-Feld (max 50 Zeichen), bedingte Felder nach §4-Matrix (Vorname/Alias/Jahresangaben nur für PERSON), lastNameLabel wechselt zu „Name" für Institution/Gruppe
  • ecf93f4b — Edit-Action: personType + title aus FormData; Vorname-Validierung typabhängig
  • 378d35d4 — Neues-Person-Formular + Server-Action analog: Typ-Selektor, Titel, bedingte Felder, Formwerte bei Fehler zurückgeben
  • 86032d5cPersonCard.svelte: Titel in Small-Caps über dem Anzeigenamen (nur Typ PERSON, nur wenn nicht leer)

Ergebnis

  • Backend: ./mvnw test1353/1353 Tests grün
  • Frontend: vitest run --project=server377/377 Tests grün
  • svelte-check: Keine neuen Fehler in geänderten Dateien
## Implementierung abgeschlossen ✅ Alle 16 Tasks wurden umgesetzt. Branch: `feat/issue-218-person-title-type-fields` ### Backend (15 neue Tests, alle grün — 1353 total) - `aac8250a` — `INVALID_PERSON_TYPE` ErrorCode + i18n-Übersetzungen (de/en/es) - `6c117611` — `personType` (@NotNull) + `title` (@Size max 50) in `PersonUpdateDTO`; `createPerson` persistiert `personType` - `fef021bf` — `createPerson` wirft `INVALID_PERSON_TYPE` bei SKIP - `39f722fe` — `updatePerson` persistiert `personType` - `60a278ad` — `updatePerson` wirft `INVALID_PERSON_TYPE` bei SKIP - `58b3dabe` — `PersonController.validatePersonNames()`: Vorname nur für Typ PERSON Pflichtfeld; Institution/Gruppe/Unbekannt erlaubt keinen Vornamen ### Frontend (23 neue Server-Tests, alle grün — 377 total) - `bf313801` — `radioGroupNav.ts` Svelte-Action: ArrowLeft/Right-Navigation, Wrap-around, `aria-checked`-Updates; 7 Browser-Tests - `e7573bbe` — SKIP→UNKNOWN-Normalisierung im `load` der Edit-Route; 6 Unit-Tests - `8f755525` — i18n-Keys: `form_label_person_type`, `form_label_name`, `a11y_type_changed` (mit `{type}`-Param) in de/en/es - `fe830ad6` — `PersonTypeSelector.svelte`: 4-Segment-Steuerlement (PERSON/INSTITUTION/GRUPPE/UNBEKANNT), `role="radiogroup"`, `use:radioGroupNav`, 2×2-Mobil-Grid, `min-h-[48px]` Touch-Targets, `aria-live="polite"`-Ankündigung - `17863869` — `PersonEditForm.svelte` neu: Typ-Selektor oben, Titel-Feld (max 50 Zeichen), bedingte Felder nach §4-Matrix (Vorname/Alias/Jahresangaben nur für PERSON), `lastNameLabel` wechselt zu „Name" für Institution/Gruppe - `ecf93f4b` — Edit-Action: `personType` + `title` aus FormData; Vorname-Validierung typabhängig - `378d35d4` — Neues-Person-Formular + Server-Action analog: Typ-Selektor, Titel, bedingte Felder, Formwerte bei Fehler zurückgeben - `86032d5c` — `PersonCard.svelte`: Titel in Small-Caps über dem Anzeigenamen (nur Typ PERSON, nur wenn nicht leer) ### Ergebnis - Backend: `./mvnw test` → **1353/1353 Tests grün** - Frontend: `vitest run --project=server` → **377/377 Tests grün** - `svelte-check`: Keine neuen Fehler in geänderten Dateien
Sign in to join this conversation.
No Label feature person ui
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#218