Person: migrate birth/death year to LocalDate + DatePrecision #773

Open
opened 2026-06-07 19:28:36 +02:00 by marcel · 7 comments
Owner

Milestone: Zeitstrahl — Family Timeline · Foundational (build first)
Spec: docs/superpowers/specs/2026-06-07-family-timeline-design.md § "Prerequisite: migrate Person birth/death to date + precision"

Context

Person stores birthYear/deathYear as Integer, so a known exact birthday (e.g. 1901-03-14) has nowhere to live and any date display is stuck at year precision. The Zeitstrahl's derived life-events need real dates with precision. This change also stands on its own: precise dates then render on person cards, hover cards, and the Stammbaum.

Scope

Replace the integer year fields on Person with date + precision:

Field Type Notes
birthDate LocalDate (nullable) most precise date known
birthDatePrecision DatePrecision NOT NULL defaults to UNKNOWN; mirrors Document.metaDatePrecision
deathDate LocalDate (nullable)
deathDatePrecision DatePrecision NOT NULL defaults to UNKNOWN

Reuse the existing DatePrecision enum (document/DatePrecision.java).

Precision-column nullability decision (resolved): precision columns are NOT NULL with default UNKNOWN, matching Document.metaDatePrecision. This prevents the illegal "date present, precision null" state and lets the DB enforce the invariant via CHECK constraints. The edit form always sends a precision value.

Valid precision set for person dates (resolved): the DB/storage accepts all 7 DatePrecision values (needed for enum-to-string mapping consistency), but the person new/edit form exposes only DAY / MONTH / YEAR. RANGE and SEASON are semantically nonsensical for a birth or death; APPROX is excluded from the form to reduce cognitive load for the 60+ author audience. Pass a PERSON_DATE_PRECISIONS = ['DAY', 'MONTH', 'YEAR'] filtered array to the precision <select> rather than forking the component.

Out of scope: PersonRelationship.fromYear (marriage) stays Integer/YEAR for now.


Data Model Change

DB schema (V72 migration — single transactional file)

File: V72__person_birth_death_to_localdate.sql

Steps (all in one file, runs atomically in Flyway's default Postgres transaction):

  1. Add the four new columns with NOT NULL DEFAULT 'UNKNOWN'.
  2. Backfill: UPDATE persons SET birth_date = make_date(birth_year, 1, 1), birth_date_precision = 'YEAR' WHERE birth_year IS NOT NULL (same for death).
  3. Add DB-level constraints:
    • CHECK (death_date IS NULL OR birth_date IS NULL OR birth_date <= death_date) — preserves the existing birth ≤ death invariant at LocalDate granularity.
    • CHECK ((birth_date IS NULL) = (birth_date_precision = 'UNKNOWN')) — forbids "date present but precision UNKNOWN" and "precision set but no date".
    • CHECK (birth_date_precision IN ('DAY','MONTH','SEASON','YEAR','RANGE','APPROX','UNKNOWN')) — guards against future enum-drift writing invalid strings (defence-in-depth).
    • Same CHECK set for death_date / death_date_precision.
  4. Drop birth_year and death_year columns.

Note (Tobias): latest migration is V71__person_delete_on_delete_fk.sql — this must be exactly V72.

Entity: Person.java

Replace Integer birthYear/deathYear with:

@Column(nullable = false, length = 16)
@Enumerated(EnumType.STRING)
@Builder.Default
private DatePrecision birthDatePrecision = DatePrecision.UNKNOWN;

private LocalDate birthDate;

@Column(nullable = false, length = 16)
@Enumerated(EnumType.STRING)
@Builder.Default
private DatePrecision deathDatePrecision = DatePrecision.UNKNOWN;

private LocalDate deathDate;

@Schema(requiredMode = REQUIRED) goes on birthDatePrecision and deathDatePrecision (always populated). birthDate/deathDate are nullable, no REQUIRED.


API

Input DTOs

PersonUpdateDTO and PersonUpsertCommand changes:

  • PersonUpdateDTO (from the frontend edit form): gains LocalDate birthDate, DatePrecision birthDatePrecision, LocalDate deathDate, DatePrecision deathDatePrecision. Jackson rejects unknown enum values by default — confirm no lenient-enum config exists; a bad precision must 400, not be silently coerced.
  • PersonUpsertCommand (from the importer): keeps Integer birthYear / Integer deathYear. The importer only knows a year; the service translates year → LocalDate(year, 1, 1) + YEAR during upsert. Do not push LocalDate into the importer — it implies a precision the spreadsheet does not have.

PersonNodeDTO (Stammbaum backward compat)

PersonNodeDTO is a Java record used by the Stammbaum. Keep exposing derived Integer birthYear/deathYear:

// In PersonService when constructing PersonNodeDTO:
Integer birthYear = person.getBirthDate() != null ? person.getBirthDate().getYear() : null;
Integer deathYear = person.getDeathDate() != null ? person.getDeathDate().getYear() : null;

REQ-PERSON-DATE-01: When Person.birthDate is null, PersonNodeDTO.birthYear shall be null. When non-null, it equals EXTRACT(YEAR FROM birthDate). This prevents NPE from birthDate.getYear() on persons with no year entered.

The native SQL queries in PersonRepository that project birth_year AS birthYear for the Stammbaum layout must project EXTRACT(YEAR FROM p.birth_date) AS birthYear after migration.


Service Logic

PersonService.validateLifeDates (replaces validateYears)

Server-side validation — must not regress existing rules:

private void validateLifeDates(LocalDate birthDate, DatePrecision birthPrec,
                                LocalDate deathDate, DatePrecision deathPrec) {
    // coherence: date present ↔ precision not UNKNOWN
    if (birthDate != null && (birthPrec == null || birthPrec == DatePrecision.UNKNOWN))
        throw DomainException.conflict(ErrorCode.INVALID_DATE_PRECISION);
    if (birthDate == null && birthPrec != null && birthPrec != DatePrecision.UNKNOWN)
        throw DomainException.conflict(ErrorCode.INVALID_DATE_PRECISION);
    // same for death side
    // temporal order
    if (birthDate != null && deathDate != null && birthDate.isAfter(deathDate))
        throw DomainException.conflict(ErrorCode.BIRTH_AFTER_DEATH);
}

Use DomainException.conflict() — never raw ResponseStatusException.

PersonService.preferHumanDate (new — replaces integer preferHuman)

Re-import precision-preservation rule (ADR-025 extension):

// Keep existing if finer than YEAR; otherwise take canonical year from spreadsheet.
LocalDate preferHumanDate(LocalDate existingDate, DatePrecision existingPrec,
                           Integer canonicalYear) {
    if (existingDate != null && existingPrec != null
            && existingPrec != DatePrecision.YEAR && existingPrec != DatePrecision.UNKNOWN) {
        return existingDate; // DAY / MONTH / SEASON — hand-entered, preserve
    }
    return canonicalYear != null ? LocalDate.of(canonicalYear, 1, 1) : null;
}

Paired method for precision: when preserving, keep existing precision; when refreshing, set YEAR.


Frontend

personLifeDates.ts — new signature

export function formatLifeDateRange(
  birthDate: string | null, birthDatePrecision: DatePrecision | null,
  deathDate: string | null, deathDatePrecision: DatePrecision | null,
  locale?: string
): string {
  const birth = birthDate
    ? `* ${formatDocumentDate(birthDate, birthDatePrecision ?? 'YEAR', null, null, locale)}`
    : null;
  const death = deathDate
    ? `† ${formatDocumentDate(deathDate, deathDatePrecision ?? 'YEAR', null, null, locale)}`
    : null;
  if (birth && death) return `${birth}${death}`;
  return birth ?? death ?? '';
}

Delegates all precision rendering to the already-tested formatDocumentDate — zero new logic.

Person new/edit forms

Replace two <input type="number"> fields for birth/death year with two date + precision groups (4 controls total). Group visually in the existing card/section pattern — one card for birth, one for death — to prevent form-explosion on narrow screens.

Reuse WhoWhenSection.svelte's German date input pattern (handleGermanDateInput, DateInput primitive) and precision <select> — but pass PERSON_DATE_PRECISIONS = ['DAY', 'MONTH', 'YEAR'] to constrain to 3 options.

Label the precision control in plain language: pair <select> with helper text "Wie genau ist dieses Datum bekannt?" so non-technical transcribers understand the choice. Touch targets ≥ 44px (prefer 48px) per WCAG 2.2.

MentionDropdown.svelte (unlisted call-site — add to scope)

Line ~328 calls formatLifeDateRange(person.birthYear, person.deathYear). After API regen, birthYear/deathYear are gone. Update to use the new signature. Verify the dropdown row uses whitespace-normal (not truncate/whitespace-nowrap) — a DAY-precision date string is ~18 chars longer than a year-only string and must wrap in the narrow autocomplete column.

PersonCard.svelte / PersonHoverCard.svelte

  • Empty-state: preserve {#if person.birthDate || person.deathDate} guard — render nothing, not an empty * –.
  • Date text is the real information; * / † glyphs are aria-hidden decorative.
  • Test at 320px: * 14. März 1901 – † 2. November 1944 must wrap, not overflow.

Tasks

  • Person.java: replace birthYear/deathYear with four fields (birthDate, birthDatePrecision NOT NULL DEFAULT UNKNOWN, deathDate, deathDatePrecision NOT NULL DEFAULT UNKNOWN).
  • Flyway V72__person_birth_death_to_localdate.sql (single file, atomic): add columns → backfill YYYY-01-01/YEAR → add CHECK constraints (birth≤death, date↔precision coherence, enum-value guard) → drop birth_year/death_year.
  • PersonRepository native SQL queries: replace all projections of p.birth_year AS birthYear / p.death_year AS deathYear with EXTRACT(YEAR FROM p.birth_date) AS birthYear / EXTRACT(YEAR FROM p.death_date) AS deathYear. Update the GROUP BY clause (currently lists p.birth_year, p.death_year) to p.birth_date, p.birth_date_precision, p.death_date, p.death_date_precision.
  • Re-import preservation (ADR-025 extension): implement preferHumanDate in PersonService; update PersonRegisterImporter / PersonTreeImporter + tools/import-normalizer/persons_tree.py to call it. PersonUpsertCommand stays year-shaped (Integer birthYear/deathYear); service translates to LocalDate + YEAR.
  • PersonUpdateDTO: add LocalDate birthDate, DatePrecision birthDatePrecision, LocalDate deathDate, DatePrecision deathDatePrecision. Jackson enum rejection must be in effect (no lenient-enum config).
  • PersonService.validateLifeDates (replaces validateYears): cross-field birth≤death check at LocalDate granularity + date/precision coherence check. Use DomainException.conflict().
  • PersonNodeDTO: null-safe year derivation in service (birthDate != null ? birthDate.getYear() : null).
  • Regenerate API types (npm run generate:api) — run before touching any frontend code.
  • personLifeDates.ts: new 5-param signature delegating to formatDocumentDate; update all callers.
  • MentionDropdown.svelte: update formatLifeDateRange call to new signature; verify whitespace-normal.
  • Person new/edit forms (PersonEditForm.svelte, +page.server.ts): date input + precision selector (DAY/MONTH/YEAR only) per birth/death, grouped in section cards; +page.server.ts parses German dd.mm.yyyy → ISO + precision.
  • PersonCard.svelte / PersonHoverCard.svelte: empty-state guard, aria-hidden on glyphs, 320px wrap test.
  • Update tests for old shape: PersonServiceTest, PersonControllerTest, PersonImportUpsertTest, PersonTreeImporterTest, PersonCard.svelte.test.ts, PersonHoverCard.svelte.spec.ts, PersonEditForm.svelte.test.ts, persons/page.svelte.test.ts — all reference birthYear/deathYear and must be updated in the same PR.
  • Write ADR-035: covers derived-year pattern for backward-compatible PersonNodeDTO, precision-preservation rule (ADR-025 extension), and precision-column NOT NULL/UNKNOWN nullability decision.
  • Update DB diagrams: docs/architecture/db/db-orm.puml and db-relationships.puml (merge blocker).
  • Deploy runbook note: after V72, the first canonical re-import must be spot-checked — confirm a known hand-edited exact date on a real person is intact.

Acceptance Criteria

  1. Migration preserves data: existing person years survive as YEAR-precision dates (YYYY-01-01, birth_date_precision = 'YEAR'); no data loss; birth_year/death_year columns no longer exist post-migration.
  2. Exact birthday renders: an exact birthday entered in the edit form (DAY precision) renders as a full date (14. März 1901 / March 14, 1901) on the person card and hover card, localized for de/en/es.
  3. Re-import preservation rule:
    • Given a person whose birthDatePrecision = DAY, when re-imported, then the hand-entered date is unchanged.
    • Given a person whose birthDatePrecision = YEAR or whose birth is empty, when re-imported with a different spreadsheet year, then the birth date updates to {new-year}-01-01 with YEAR precision.
  4. Cross-field invariant enforced: if a birth date is after a death date, the edit is rejected with a validation error (400). Equal dates are allowed.
  5. Stammbaum unchanged: birth/death years render identically on the Stammbaum before and after migration.
  6. Empty-state: a person with no dates renders no life-date line (not an empty * –).
  7. Native queries work post-migration: all person list, search, and document-linked person views (including the GROUP BY path) display correct years — not 500s from BadSqlGrammarException.
  8. All person list / search views continue to display birth/death years (as YEAR-precision formatted dates) via native query paths, not just the entity layer.

Tests

Migration (Testcontainers postgres:16-alpine — NOT H2; H2 won't honor CHECK constraints)

  • birth_year present, death_year null → birth_date='YYYY-01-01'/YEAR, death remains null/UNKNOWN.
  • Both null → both date+precision null/UNKNOWN (no spurious 0000-01-01).
  • Death-only person (birth_year null, death_year set).
  • Verify backfill cannot create a birth_date > death_date row (assert post-backfill).
  • Column drop verified: querying birth_year after migration fails with an error.
  • Native query projection test: personRepository.findSummaries() returns rows with birthYear = 1901 (via EXTRACT) after migration — proves the query aliases are correct.
  • GROUP BY path test: exercise the multi-document-persons query (the one with GROUP BY p.birth_year) to confirm it works after the GROUP BY clause update.

Service / importer (unit, Mockito)

  • preferHumanDate: re-import preserves DAY/MONTH/SEASON-precision hand-entered date.
  • preferHumanDate: re-import refreshes a YEAR/UNKNOWN-precision date when the spreadsheet year changes (proves it's not "never overwrite").
  • preferHumanDate: re-import into an empty field fills at YEAR precision.
  • validateLifeDates: birth after death rejected; equal dates allowed; null sides allowed.
  • validateLifeDates: birthDate non-null with precision UNKNOWN → rejected (coherence check).
  • validateLifeDates: birthDate null with precision DAY → rejected (coherence check).

Python (persons_tree.py)

  • _parse_year extraction is unchanged; emitted shape still carries the year; importer-side precision defaults to YEAR. Test confirms year extraction only — the Python side never sees precision.

Frontend (*.spec.ts, browser mode)

  • personLifeDates.spec.ts: one assertion per DatePrecision (DAY/MONTH/YEAR/UNKNOWN) × {birth-only, death-only, both}. Include locale en/es for at least DAY/MONTH to catch German-month-leak class of bug.
  • PersonEditForm.svelte.test.ts (4 fields, not 2):
    • renders existing DAY-precision birth date as dd.mm.yyyy in the date input.
    • precision select shows only DAY / MONTH / YEAR options.
    • form action in +page.server.ts parses German date + precision correctly.
    • enter exact birthday → renders full date on card (the acceptance criterion as an automated test).
  • MentionDropdown.svelte.spec.ts: a person with DAY-precision birthDate renders a full date string in the dropdown (behavioral test, not just TS compile check).
  • PersonCard.svelte.test.ts / PersonHoverCard.svelte.spec.ts: updated for new field shapes; 320px wrap does not overflow.

Decisions Resolved

Decision Resolution Rationale
Precision-column nullability NOT NULL, default UNKNOWN Mirrors Document.metaDatePrecision; DB forbids "date present, precision null" via CHECK; stronger invariant with one extra migration line
Valid precision values in person form DAY / MONTH / YEAR only (storage keeps all 7) RANGE and SEASON are nonsensical for birth/death; prevents trap choices for 60+ author audience; pass PERSON_DATE_PRECISIONS array to the select
PersonNodeDTO year derivation Service-level null-safe construction (birthDate != null ? birthDate.getYear() : null) Explicit, testable, no Hibernate magic; native queries project EXTRACT(YEAR FROM p.birth_date) AS birthYear
PersonUpsertCommand shape Stays Integer birthYear/deathYear Importer only knows a year; service translates to LocalDate + YEAR; avoids implying a precision the spreadsheet doesn't have
MentionDropdown scope In scope — update formatLifeDateRange call and verify whitespace-normal Unlisted call-site that breaks at compile time after API regen; too small to defer
ADR number ADR-035 (ADR-034 is the current latest) Lock in to prevent collision with parallel in-flight PRs
Flyway migration number V72 (V71__person_delete_on_delete_fk.sql is latest) Confirmed by Tobias
**Milestone:** Zeitstrahl — Family Timeline · **Foundational (build first)** **Spec:** `docs/superpowers/specs/2026-06-07-family-timeline-design.md` § "Prerequisite: migrate Person birth/death to date + precision" ## Context `Person` stores `birthYear`/`deathYear` as `Integer`, so a known exact birthday (e.g. `1901-03-14`) has nowhere to live and any date display is stuck at year precision. The Zeitstrahl's derived life-events need real dates with precision. This change also stands on its own: precise dates then render on person cards, hover cards, and the Stammbaum. ## Scope Replace the integer year fields on `Person` with date + precision: | Field | Type | Notes | |---|---|---| | `birthDate` | `LocalDate` (nullable) | most precise date known | | `birthDatePrecision` | `DatePrecision` NOT NULL | defaults to `UNKNOWN`; mirrors `Document.metaDatePrecision` | | `deathDate` | `LocalDate` (nullable) | | | `deathDatePrecision` | `DatePrecision` NOT NULL | defaults to `UNKNOWN` | Reuse the existing `DatePrecision` enum (`document/DatePrecision.java`). **Precision-column nullability decision (resolved):** precision columns are NOT NULL with default `UNKNOWN`, matching `Document.metaDatePrecision`. This prevents the illegal "date present, precision null" state and lets the DB enforce the invariant via CHECK constraints. The edit form always sends a precision value. **Valid precision set for person dates (resolved):** the DB/storage accepts all 7 `DatePrecision` values (needed for enum-to-string mapping consistency), but the person new/edit form exposes **only DAY / MONTH / YEAR**. `RANGE` and `SEASON` are semantically nonsensical for a birth or death; `APPROX` is excluded from the form to reduce cognitive load for the 60+ author audience. Pass a `PERSON_DATE_PRECISIONS = ['DAY', 'MONTH', 'YEAR']` filtered array to the precision `<select>` rather than forking the component. **Out of scope:** `PersonRelationship.fromYear` (marriage) stays `Integer`/`YEAR` for now. --- ## Data Model Change ### DB schema (V72 migration — single transactional file) File: `V72__person_birth_death_to_localdate.sql` Steps (all in one file, runs atomically in Flyway's default Postgres transaction): 1. Add the four new columns with `NOT NULL DEFAULT 'UNKNOWN'`. 2. Backfill: `UPDATE persons SET birth_date = make_date(birth_year, 1, 1), birth_date_precision = 'YEAR' WHERE birth_year IS NOT NULL` (same for death). 3. Add DB-level constraints: - `CHECK (death_date IS NULL OR birth_date IS NULL OR birth_date <= death_date)` — preserves the existing birth ≤ death invariant at `LocalDate` granularity. - `CHECK ((birth_date IS NULL) = (birth_date_precision = 'UNKNOWN'))` — forbids "date present but precision UNKNOWN" and "precision set but no date". - `CHECK (birth_date_precision IN ('DAY','MONTH','SEASON','YEAR','RANGE','APPROX','UNKNOWN'))` — guards against future enum-drift writing invalid strings (defence-in-depth). - Same CHECK set for `death_date` / `death_date_precision`. 4. Drop `birth_year` and `death_year` columns. **Note (Tobias):** latest migration is `V71__person_delete_on_delete_fk.sql` — this must be exactly `V72`. ### Entity: `Person.java` Replace `Integer birthYear`/`deathYear` with: ```java @Column(nullable = false, length = 16) @Enumerated(EnumType.STRING) @Builder.Default private DatePrecision birthDatePrecision = DatePrecision.UNKNOWN; private LocalDate birthDate; @Column(nullable = false, length = 16) @Enumerated(EnumType.STRING) @Builder.Default private DatePrecision deathDatePrecision = DatePrecision.UNKNOWN; private LocalDate deathDate; ``` `@Schema(requiredMode = REQUIRED)` goes on `birthDatePrecision` and `deathDatePrecision` (always populated). `birthDate`/`deathDate` are nullable, no `REQUIRED`. --- ## API ### Input DTOs `PersonUpdateDTO` and `PersonUpsertCommand` changes: - **`PersonUpdateDTO`** (from the frontend edit form): gains `LocalDate birthDate`, `DatePrecision birthDatePrecision`, `LocalDate deathDate`, `DatePrecision deathDatePrecision`. Jackson rejects unknown enum values by default — confirm no lenient-enum config exists; a bad precision must 400, not be silently coerced. - **`PersonUpsertCommand`** (from the importer): **keeps** `Integer birthYear` / `Integer deathYear`. The importer only knows a year; the service translates `year → LocalDate(year, 1, 1) + YEAR` during upsert. Do not push `LocalDate` into the importer — it implies a precision the spreadsheet does not have. ### `PersonNodeDTO` (Stammbaum backward compat) `PersonNodeDTO` is a Java record used by the Stammbaum. Keep exposing derived `Integer birthYear`/`deathYear`: ```java // In PersonService when constructing PersonNodeDTO: Integer birthYear = person.getBirthDate() != null ? person.getBirthDate().getYear() : null; Integer deathYear = person.getDeathDate() != null ? person.getDeathDate().getYear() : null; ``` **REQ-PERSON-DATE-01:** When `Person.birthDate` is null, `PersonNodeDTO.birthYear` shall be null. When non-null, it equals `EXTRACT(YEAR FROM birthDate)`. This prevents NPE from `birthDate.getYear()` on persons with no year entered. The native SQL queries in `PersonRepository` that project `birth_year AS birthYear` for the Stammbaum layout must project `EXTRACT(YEAR FROM p.birth_date) AS birthYear` after migration. --- ## Service Logic ### `PersonService.validateLifeDates` (replaces `validateYears`) Server-side validation — must not regress existing rules: ```java private void validateLifeDates(LocalDate birthDate, DatePrecision birthPrec, LocalDate deathDate, DatePrecision deathPrec) { // coherence: date present ↔ precision not UNKNOWN if (birthDate != null && (birthPrec == null || birthPrec == DatePrecision.UNKNOWN)) throw DomainException.conflict(ErrorCode.INVALID_DATE_PRECISION); if (birthDate == null && birthPrec != null && birthPrec != DatePrecision.UNKNOWN) throw DomainException.conflict(ErrorCode.INVALID_DATE_PRECISION); // same for death side // temporal order if (birthDate != null && deathDate != null && birthDate.isAfter(deathDate)) throw DomainException.conflict(ErrorCode.BIRTH_AFTER_DEATH); } ``` Use `DomainException.conflict()` — never raw `ResponseStatusException`. ### `PersonService.preferHumanDate` (new — replaces integer `preferHuman`) Re-import precision-preservation rule (ADR-025 extension): ```java // Keep existing if finer than YEAR; otherwise take canonical year from spreadsheet. LocalDate preferHumanDate(LocalDate existingDate, DatePrecision existingPrec, Integer canonicalYear) { if (existingDate != null && existingPrec != null && existingPrec != DatePrecision.YEAR && existingPrec != DatePrecision.UNKNOWN) { return existingDate; // DAY / MONTH / SEASON — hand-entered, preserve } return canonicalYear != null ? LocalDate.of(canonicalYear, 1, 1) : null; } ``` Paired method for precision: when preserving, keep existing precision; when refreshing, set `YEAR`. --- ## Frontend ### `personLifeDates.ts` — new signature ```typescript export function formatLifeDateRange( birthDate: string | null, birthDatePrecision: DatePrecision | null, deathDate: string | null, deathDatePrecision: DatePrecision | null, locale?: string ): string { const birth = birthDate ? `* ${formatDocumentDate(birthDate, birthDatePrecision ?? 'YEAR', null, null, locale)}` : null; const death = deathDate ? `† ${formatDocumentDate(deathDate, deathDatePrecision ?? 'YEAR', null, null, locale)}` : null; if (birth && death) return `${birth} – ${death}`; return birth ?? death ?? ''; } ``` Delegates all precision rendering to the already-tested `formatDocumentDate` — zero new logic. ### Person new/edit forms Replace two `<input type="number">` fields for birth/death year with two date + precision groups (4 controls total). Group visually in the existing card/section pattern — one card for birth, one for death — to prevent form-explosion on narrow screens. Reuse `WhoWhenSection.svelte`'s German date input pattern (`handleGermanDateInput`, `DateInput` primitive) and precision `<select>` — but pass `PERSON_DATE_PRECISIONS = ['DAY', 'MONTH', 'YEAR']` to constrain to 3 options. Label the precision control in plain language: pair `<select>` with helper text "Wie genau ist dieses Datum bekannt?" so non-technical transcribers understand the choice. Touch targets ≥ 44px (prefer 48px) per WCAG 2.2. ### `MentionDropdown.svelte` (unlisted call-site — add to scope) Line ~328 calls `formatLifeDateRange(person.birthYear, person.deathYear)`. After API regen, `birthYear`/`deathYear` are gone. Update to use the new signature. Verify the dropdown row uses `whitespace-normal` (not `truncate`/`whitespace-nowrap`) — a DAY-precision date string is ~18 chars longer than a year-only string and must wrap in the narrow autocomplete column. ### `PersonCard.svelte` / `PersonHoverCard.svelte` - Empty-state: preserve `{#if person.birthDate || person.deathDate}` guard — render nothing, not an empty `* –`. - Date text is the real information; `* / †` glyphs are `aria-hidden` decorative. - Test at 320px: `* 14. März 1901 – † 2. November 1944` must wrap, not overflow. --- ## Tasks - [ ] **`Person.java`**: replace `birthYear`/`deathYear` with four fields (`birthDate`, `birthDatePrecision NOT NULL DEFAULT UNKNOWN`, `deathDate`, `deathDatePrecision NOT NULL DEFAULT UNKNOWN`). - [ ] **Flyway `V72__person_birth_death_to_localdate.sql`** (single file, atomic): add columns → backfill `YYYY-01-01`/`YEAR` → add CHECK constraints (birth≤death, date↔precision coherence, enum-value guard) → drop `birth_year`/`death_year`. - [ ] **`PersonRepository` native SQL queries**: replace all projections of `p.birth_year AS birthYear` / `p.death_year AS deathYear` with `EXTRACT(YEAR FROM p.birth_date) AS birthYear` / `EXTRACT(YEAR FROM p.death_date) AS deathYear`. Update the `GROUP BY` clause (currently lists `p.birth_year, p.death_year`) to `p.birth_date, p.birth_date_precision, p.death_date, p.death_date_precision`. - [ ] **Re-import preservation (ADR-025 extension):** implement `preferHumanDate` in `PersonService`; update `PersonRegisterImporter` / `PersonTreeImporter` + `tools/import-normalizer/persons_tree.py` to call it. `PersonUpsertCommand` stays year-shaped (`Integer birthYear`/`deathYear`); service translates to `LocalDate + YEAR`. - [ ] **`PersonUpdateDTO`**: add `LocalDate birthDate`, `DatePrecision birthDatePrecision`, `LocalDate deathDate`, `DatePrecision deathDatePrecision`. Jackson enum rejection must be in effect (no lenient-enum config). - [ ] **`PersonService.validateLifeDates`** (replaces `validateYears`): cross-field birth≤death check at `LocalDate` granularity + date/precision coherence check. Use `DomainException.conflict()`. - [ ] **`PersonNodeDTO`**: null-safe year derivation in service (`birthDate != null ? birthDate.getYear() : null`). - [ ] **Regenerate API types** (`npm run generate:api`) — run before touching any frontend code. - [ ] **`personLifeDates.ts`**: new 5-param signature delegating to `formatDocumentDate`; update all callers. - [ ] **`MentionDropdown.svelte`**: update `formatLifeDateRange` call to new signature; verify `whitespace-normal`. - [ ] **Person new/edit forms** (`PersonEditForm.svelte`, `+page.server.ts`): date input + precision selector (DAY/MONTH/YEAR only) per birth/death, grouped in section cards; `+page.server.ts` parses German dd.mm.yyyy → ISO + precision. - [ ] **`PersonCard.svelte` / `PersonHoverCard.svelte`**: empty-state guard, `aria-hidden` on glyphs, 320px wrap test. - [ ] **Update tests** for old shape: `PersonServiceTest`, `PersonControllerTest`, `PersonImportUpsertTest`, `PersonTreeImporterTest`, `PersonCard.svelte.test.ts`, `PersonHoverCard.svelte.spec.ts`, `PersonEditForm.svelte.test.ts`, `persons/page.svelte.test.ts` — all reference `birthYear`/`deathYear` and must be updated in the same PR. - [ ] **Write ADR-035**: covers derived-year pattern for backward-compatible `PersonNodeDTO`, precision-preservation rule (ADR-025 extension), and precision-column NOT NULL/UNKNOWN nullability decision. - [ ] **Update DB diagrams**: `docs/architecture/db/db-orm.puml` and `db-relationships.puml` (merge blocker). - [ ] **Deploy runbook note**: after V72, the first canonical re-import must be spot-checked — confirm a known hand-edited exact date on a real person is intact. --- ## Acceptance Criteria 1. **Migration preserves data:** existing person years survive as `YEAR`-precision dates (`YYYY-01-01`, `birth_date_precision = 'YEAR'`); no data loss; `birth_year`/`death_year` columns no longer exist post-migration. 2. **Exact birthday renders:** an exact birthday entered in the edit form (DAY precision) renders as a full date (`14. März 1901` / `March 14, 1901`) on the person card and hover card, localized for `de`/`en`/`es`. 3. **Re-import preservation rule:** - *Given* a person whose `birthDatePrecision = DAY`, *when* re-imported, *then* the hand-entered date is unchanged. - *Given* a person whose `birthDatePrecision = YEAR` or whose birth is empty, *when* re-imported with a different spreadsheet year, *then* the birth date updates to `{new-year}-01-01` with `YEAR` precision. 4. **Cross-field invariant enforced:** if a birth date is after a death date, the edit is rejected with a validation error (400). Equal dates are allowed. 5. **Stammbaum unchanged:** birth/death years render identically on the Stammbaum before and after migration. 6. **Empty-state:** a person with no dates renders no life-date line (not an empty `* –`). 7. **Native queries work post-migration:** all person list, search, and document-linked person views (including the GROUP BY path) display correct years — not 500s from `BadSqlGrammarException`. 8. **All person list / search views** continue to display birth/death years (as `YEAR`-precision formatted dates) via native query paths, not just the entity layer. --- ## Tests ### Migration (Testcontainers `postgres:16-alpine` — NOT H2; H2 won't honor CHECK constraints) - `birth_year` present, `death_year` null → `birth_date='YYYY-01-01'/YEAR`, death remains null/UNKNOWN. - Both null → both date+precision null/UNKNOWN (no spurious `0000-01-01`). - Death-only person (`birth_year` null, `death_year` set). - Verify backfill cannot create a `birth_date > death_date` row (assert post-backfill). - Column drop verified: querying `birth_year` after migration fails with an error. - **Native query projection test:** `personRepository.findSummaries()` returns rows with `birthYear = 1901` (via `EXTRACT`) after migration — proves the query aliases are correct. - **GROUP BY path test:** exercise the multi-document-persons query (the one with `GROUP BY p.birth_year`) to confirm it works after the `GROUP BY` clause update. ### Service / importer (unit, Mockito) - `preferHumanDate`: re-import preserves DAY/MONTH/SEASON-precision hand-entered date. - `preferHumanDate`: re-import refreshes a YEAR/UNKNOWN-precision date when the spreadsheet year changes (proves it's not "never overwrite"). - `preferHumanDate`: re-import into an empty field fills at YEAR precision. - `validateLifeDates`: birth after death rejected; equal dates allowed; null sides allowed. - `validateLifeDates`: birthDate non-null with precision UNKNOWN → rejected (coherence check). - `validateLifeDates`: birthDate null with precision DAY → rejected (coherence check). ### Python (`persons_tree.py`) - `_parse_year` extraction is unchanged; emitted shape still carries the year; importer-side precision defaults to YEAR. Test confirms year extraction only — the Python side never sees precision. ### Frontend (`*.spec.ts`, browser mode) - `personLifeDates.spec.ts`: one assertion per `DatePrecision` (DAY/MONTH/YEAR/UNKNOWN) × {birth-only, death-only, both}. Include locale `en`/`es` for at least DAY/MONTH to catch German-month-leak class of bug. - `PersonEditForm.svelte.test.ts` (4 fields, not 2): - renders existing DAY-precision birth date as `dd.mm.yyyy` in the date input. - precision select shows only DAY / MONTH / YEAR options. - form action in `+page.server.ts` parses German date + precision correctly. - enter exact birthday → renders full date on card (the acceptance criterion as an automated test). - `MentionDropdown.svelte.spec.ts`: a person with DAY-precision `birthDate` renders a full date string in the dropdown (behavioral test, not just TS compile check). - `PersonCard.svelte.test.ts` / `PersonHoverCard.svelte.spec.ts`: updated for new field shapes; 320px wrap does not overflow. --- ## Decisions Resolved | Decision | Resolution | Rationale | |---|---|---| | Precision-column nullability | **NOT NULL, default `UNKNOWN`** | Mirrors `Document.metaDatePrecision`; DB forbids "date present, precision null" via CHECK; stronger invariant with one extra migration line | | Valid precision values in person form | **DAY / MONTH / YEAR only** (storage keeps all 7) | `RANGE` and `SEASON` are nonsensical for birth/death; prevents trap choices for 60+ author audience; pass `PERSON_DATE_PRECISIONS` array to the select | | `PersonNodeDTO` year derivation | **Service-level null-safe construction** (`birthDate != null ? birthDate.getYear() : null`) | Explicit, testable, no Hibernate magic; native queries project `EXTRACT(YEAR FROM p.birth_date) AS birthYear` | | `PersonUpsertCommand` shape | **Stays `Integer birthYear`/`deathYear`** | Importer only knows a year; service translates to `LocalDate + YEAR`; avoids implying a precision the spreadsheet doesn't have | | MentionDropdown scope | **In scope** — update `formatLifeDateRange` call and verify `whitespace-normal` | Unlisted call-site that breaks at compile time after API regen; too small to defer | | ADR number | **ADR-035** (ADR-034 is the current latest) | Lock in to prevent collision with parallel in-flight PRs | | Flyway migration number | **V72** (`V71__person_delete_on_delete_fk.sql` is latest) | Confirmed by Tobias |
marcel added this to the Zeitstrahl — Family Timeline milestone 2026-06-07 19:28:36 +02:00
marcel added the P1-highfeatureperson labels 2026-06-07 19:29:56 +02:00
Author
Owner

🏗️ Markus Keller — Application Architect

Observations

The issue is architecturally clean. Key strengths:

  • Pushing the (date IS NULL) = (precision = 'UNKNOWN') invariant to the DB via CHECK is exactly right — enforced atomically, not app-layer only.
  • PersonNodeDTO keeping Integer birthYear/deathYear as a derived value is the correct backward-compat pattern for the Stammbaum.
  • PersonUpsertCommand staying year-shaped keeps the importer contract simple and prevents false-precision leakage from the spreadsheet.

Three architectural gaps I found that the issue does not address:

1. PersonSummaryDTO interface projection is the primary blast radius the issue underspecifies.
The interface at PersonSummaryDTO.java has Integer getBirthYear() and Integer getDeathYear(). This is the return type of every native SQL query in PersonRepositoryfindAllWithDocumentCount, searchWithDocumentCount, findTopByDocumentCount, findByFilter. The issue mentions GROUP BY updates but does not explicitly list PersonSummaryDTO in the interface update task, nor does it cover the @Schema implications on that interface. The native queries project p.birth_year AS birthYear in 4 separate SQL strings — all must change to EXTRACT(YEAR FROM p.birth_date) AS birthYear, plus GROUP BY p.id, ..., p.birth_date, p.birth_date_precision, p.death_date, p.death_date_precision. This is listed in the Tasks but the interface change to PersonSummaryDTO is absent.

2. ADR-025 cross-reference is under-specified.
The preferHumanDate function extends ADR-025. ADR-025 covers the preferHuman pattern for string/integer fields. The new preferHumanDate for LocalDate + precision is a compound type — the issue describes it well in prose but the ADR-035 task should explicitly note it supersedes the integer preferHuman for date fields. Otherwise a future developer might apply the old integer preferHuman to the new date fields.

3. The PersonSummaryDTO interface projection needs getBirthDate()/getBirthDatePrecision() or we lose the ability to render precision-aware dates in the persons list.
Currently the list only shows year (birthYear). After migration, if someone wants to show a DAY-precision date in the person directory, PersonSummaryDTO would need the new fields. However, the issue resolves this by keeping EXTRACT(YEAR FROM ...) — a conscious scope decision. This is fine for MVP but should be noted in ADR-035 as a known limitation: the person directory always shows year precision even for persons with exact birth dates.

Recommendations

  • Add PersonSummaryDTO interface changes explicitly to the task list: Integer getBirthYear() stays (derived via EXTRACT), but the 4 native SQL queries each need the GROUP BY clause updated — list all 4 by method name so nothing is missed during implementation.
  • In ADR-035, document that PersonSummaryDTO intentionally does not expose birthDate/birthDatePrecision — person list shows year precision only; full precision is available on the person detail page. This prevents a future refactor that accidentally adds LocalDate getBirthDate() to the interface without updating the 4 native SQL queries.
  • The FILTER_WHERE constant in PersonRepository is shared between findByFilter and countByFilter — the GROUP BY clause is not in FILTER_WHERE, so both queries need independent updates. The GROUP BY in searchWithDocumentCount also references p.birth_year, p.death_year explicitly and must be updated.

Open Decisions (none — all key decisions are resolved in the issue)

## 🏗️ Markus Keller — Application Architect ### Observations The issue is architecturally clean. Key strengths: - Pushing the `(date IS NULL) = (precision = 'UNKNOWN')` invariant to the DB via CHECK is exactly right — enforced atomically, not app-layer only. - `PersonNodeDTO` keeping `Integer birthYear`/`deathYear` as a derived value is the correct backward-compat pattern for the Stammbaum. - `PersonUpsertCommand` staying year-shaped keeps the importer contract simple and prevents false-precision leakage from the spreadsheet. **Three architectural gaps I found that the issue does not address:** **1. `PersonSummaryDTO` interface projection is the primary blast radius the issue underspecifies.** The interface at `PersonSummaryDTO.java` has `Integer getBirthYear()` and `Integer getDeathYear()`. This is the return type of every native SQL query in `PersonRepository` — `findAllWithDocumentCount`, `searchWithDocumentCount`, `findTopByDocumentCount`, `findByFilter`. The issue mentions `GROUP BY` updates but does not explicitly list `PersonSummaryDTO` in the interface update task, nor does it cover the `@Schema` implications on that interface. The native queries project `p.birth_year AS birthYear` in 4 separate SQL strings — all must change to `EXTRACT(YEAR FROM p.birth_date) AS birthYear`, plus `GROUP BY p.id, ..., p.birth_date, p.birth_date_precision, p.death_date, p.death_date_precision`. This is listed in the Tasks but the interface change to `PersonSummaryDTO` is absent. **2. ADR-025 cross-reference is under-specified.** The `preferHumanDate` function extends ADR-025. ADR-025 covers the `preferHuman` pattern for string/integer fields. The new `preferHumanDate` for `LocalDate + precision` is a compound type — the issue describes it well in prose but the ADR-035 task should explicitly note it supersedes the integer `preferHuman` for date fields. Otherwise a future developer might apply the old integer `preferHuman` to the new date fields. **3. The `PersonSummaryDTO` interface projection needs `getBirthDate()`/`getBirthDatePrecision()` or we lose the ability to render precision-aware dates in the persons list.** Currently the list only shows year (`birthYear`). After migration, if someone wants to show a DAY-precision date in the person directory, `PersonSummaryDTO` would need the new fields. However, the issue resolves this by keeping `EXTRACT(YEAR FROM ...)` — a conscious scope decision. This is fine for MVP but should be noted in ADR-035 as a known limitation: the person directory always shows year precision even for persons with exact birth dates. ### Recommendations - Add `PersonSummaryDTO` interface changes explicitly to the task list: `Integer getBirthYear()` stays (derived via `EXTRACT`), but the 4 native SQL queries each need the `GROUP BY` clause updated — list all 4 by method name so nothing is missed during implementation. - In ADR-035, document that `PersonSummaryDTO` intentionally does not expose `birthDate`/`birthDatePrecision` — person list shows year precision only; full precision is available on the person detail page. This prevents a future refactor that accidentally adds `LocalDate getBirthDate()` to the interface without updating the 4 native SQL queries. - The `FILTER_WHERE` constant in `PersonRepository` is shared between `findByFilter` and `countByFilter` — the `GROUP BY` clause is not in `FILTER_WHERE`, so both queries need independent updates. The `GROUP BY` in `searchWithDocumentCount` also references `p.birth_year, p.death_year` explicitly and must be updated. ### Open Decisions _(none — all key decisions are resolved in the issue)_
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Observations

Codebase-grounded review after reading Person.java, PersonRepository.java, PersonService.java, PersonUpdateDTO.java, PersonSummaryDTO.java, PersonNodeDTO.java, personLifeDates.ts, MentionDropdown.svelte, and PersonEditForm.svelte.

The issue has excellent implementation detail, but a few code-level gaps:

1. PersonUpdateDTO currently uses @Min/@Max Bean Validation on the generation field — the new LocalDate / DatePrecision fields need similar validation annotations or the DTO leaves Jackson as sole defence.
Jackson rejects unknown enum values by default (as the issue notes), but LocalDate deserialization from a badly-formatted string will throw a 400 automatically only if you have Jackson's JavaTimeModule registered (which Spring Boot autoconfigures). Confirm this is active. The bigger risk: the issue says "Jackson rejects unknown enum values" but doesn't list adding @JsonCreator or confirming DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES is in effect. Since the issue's own check says "confirm no lenient-enum config exists", add this as an explicit test assertion in PersonControllerTest, not just prose.

2. preferHumanDate is defined at the service level but the issue's pseudocode returns LocalDate without the paired precision.
The precision-return case is mentioned ("Paired method for precision: when preserving, keep existing precision") but is described as a separate method. Consider making preferHumanDate return a record record DatePrecisionPair(LocalDate date, DatePrecision precision) {} to keep the two values atomic and eliminate the risk of the date and precision going out of sync if only one method is called.

3. PersonEditForm.svelte currently has two <input type="number"> fields for birth/death year (confirmed at lines 92–113 of the file). The issue describes replacing these with date + precision groups using WhoWhenSection.svelte's pattern.
The handleGermanDateInput utility already exists in WhoWhenSection.svelte — but it is not currently exported from a shared utility module. Before implementing, check whether it needs to be extracted to $lib/shared/utils/ or can be imported directly from WhoWhenSection.svelte. Copying the logic creates a duplication violation.

4. PersonSummaryDTO is an interface projection, not a class. The native query returns EXTRACT(YEAR FROM p.birth_date) AS birthYear — this aliases to the getter getBirthYear(). Test this carefully: Spring Data JPA interface projections use the getter name as the alias key. The alias in the native query must exactly match the getter (case-insensitive in Postgres, but verify). Write a @DataJpaTest slice test for PersonRepository.findAllWithDocumentCount() post-migration — this is listed in the issue as a test but should be called out explicitly as a @DataJpaTest not a full @SpringBootTest.

5. The new BIRTH_AFTER_DEATH and INVALID_DATE_PRECISION error codes are referenced in validateLifeDates but the existing ErrorCode.java does not contain them (it has INVALID_DATE_RANGE which is different). These must be added to ErrorCode.java and mirrored to the frontend errors.ts — this is documented in the CLAUDE.md pattern but not explicitly in the task list. Add it.

Recommendations

  • Make preferHumanDate + preferHumanPrecision into one method returning a small record/pair — avoids the invariant split.
  • Extract handleGermanDateInput to $lib/shared/utils/germanDate.ts (or confirm the existing location) before the PersonEditForm PR touches it — keeps DRY.
  • Add explicit task: "Add BIRTH_AFTER_DEATH and INVALID_DATE_PRECISION to ErrorCode.java + errors.ts + i18n message files."
  • The PersonEditForm.svelte server action (+page.server.ts) must parse the German dd.mm.yyyy date string to ISO before sending to the API. Add an explicit test for the malformed date input path (e.g. "not-a-date" → form returns 400 with a human-readable message, not a Jackson deserialization error stack trace).
## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### Observations Codebase-grounded review after reading `Person.java`, `PersonRepository.java`, `PersonService.java`, `PersonUpdateDTO.java`, `PersonSummaryDTO.java`, `PersonNodeDTO.java`, `personLifeDates.ts`, `MentionDropdown.svelte`, and `PersonEditForm.svelte`. **The issue has excellent implementation detail, but a few code-level gaps:** **1. `PersonUpdateDTO` currently uses `@Min`/`@Max` Bean Validation on the `generation` field — the new `LocalDate` / `DatePrecision` fields need similar validation annotations or the DTO leaves Jackson as sole defence.** Jackson rejects unknown enum values by default (as the issue notes), but `LocalDate` deserialization from a badly-formatted string will throw a 400 automatically only if you have Jackson's `JavaTimeModule` registered (which Spring Boot autoconfigures). Confirm this is active. The bigger risk: the issue says "Jackson rejects unknown enum values" but doesn't list adding `@JsonCreator` or confirming `DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES` is in effect. Since the issue's own check says "confirm no lenient-enum config exists", add this as an explicit test assertion in `PersonControllerTest`, not just prose. **2. `preferHumanDate` is defined at the service level but the issue's pseudocode returns `LocalDate` without the paired precision.** The precision-return case is mentioned ("Paired method for precision: when preserving, keep existing precision") but is described as a separate method. Consider making `preferHumanDate` return a record `record DatePrecisionPair(LocalDate date, DatePrecision precision) {}` to keep the two values atomic and eliminate the risk of the date and precision going out of sync if only one method is called. **3. `PersonEditForm.svelte` currently has two `<input type="number">` fields for birth/death year (confirmed at lines 92–113 of the file). The issue describes replacing these with date + precision groups using `WhoWhenSection.svelte`'s pattern.** The `handleGermanDateInput` utility already exists in `WhoWhenSection.svelte` — but it is not currently exported from a shared utility module. Before implementing, check whether it needs to be extracted to `$lib/shared/utils/` or can be imported directly from `WhoWhenSection.svelte`. Copying the logic creates a duplication violation. **4. `PersonSummaryDTO` is an interface projection, not a class.** The native query returns `EXTRACT(YEAR FROM p.birth_date) AS birthYear` — this aliases to the getter `getBirthYear()`. Test this carefully: Spring Data JPA interface projections use the getter name as the alias key. The alias in the native query must exactly match the getter (case-insensitive in Postgres, but verify). Write a `@DataJpaTest` slice test for `PersonRepository.findAllWithDocumentCount()` post-migration — this is listed in the issue as a test but should be called out explicitly as a `@DataJpaTest` not a full `@SpringBootTest`. **5. The new `BIRTH_AFTER_DEATH` and `INVALID_DATE_PRECISION` error codes are referenced in `validateLifeDates` but the existing `ErrorCode.java` does not contain them** (it has `INVALID_DATE_RANGE` which is different). These must be added to `ErrorCode.java` and mirrored to the frontend `errors.ts` — this is documented in the CLAUDE.md pattern but not explicitly in the task list. Add it. ### Recommendations - Make `preferHumanDate` + `preferHumanPrecision` into one method returning a small record/pair — avoids the invariant split. - Extract `handleGermanDateInput` to `$lib/shared/utils/germanDate.ts` (or confirm the existing location) before the PersonEditForm PR touches it — keeps DRY. - Add explicit task: "Add `BIRTH_AFTER_DEATH` and `INVALID_DATE_PRECISION` to `ErrorCode.java` + `errors.ts` + i18n message files." - The `PersonEditForm.svelte` server action (`+page.server.ts`) must parse the German `dd.mm.yyyy` date string to ISO before sending to the API. Add an explicit test for the malformed date input path (e.g. `"not-a-date"` → form returns 400 with a human-readable message, not a Jackson deserialization error stack trace).
Author
Owner

🔒 Nora Steiner — Application Security Engineer

Observations

No authentication or authorization changes in this issue — the @RequirePermission(Permission.WRITE_ALL) pattern on person write endpoints already covers this feature, and I confirmed that PersonController uses it. No new Permission values are introduced. Focused review on input validation and enum security.

Three security findings:

1. Jackson enum rejection — "confirm no lenient-enum config" is a manual step, not a test. This needs a test.
The issue correctly notes that Jackson rejects unknown enum values by default, and asks to "confirm no lenient-enum config exists." The correct fix is not just a check — it's an automated test. A PersonControllerTest case that sends "birthDatePrecision": "BOGUS_VALUE" to PUT /api/persons/{id} should return 400. Without this test, a future developer can accidentally add @JsonProperty(access = WRITE_ONLY) or configure ALLOW_COERCION_OF_SCALARS and silently break enum validation. Add this as a test case.

2. The +page.server.ts date parsing from German format (dd.mm.yyyy → ISO) is a new user-controlled string transformation that needs sanitization.
If someone enters "../../etc/passwd" or "99.99.9999" as a date, the server action must validate before forwarding to the backend. The backend will reject it via Jackson's LocalDate deserializer, but the error message returned to the browser must be a friendly i18n message, not Jackson's deserialization exception detail (which would leak implementation info). Explicitly test the malformed-input path: the form action must catch the backend 400 and surface a localized error string via fail(400, { error: ... }).

3. The DB CHECK constraints are the right defense layer — verify they are expressed as named constraints for error diagnosis.
CHECK (birth_date IS NULL OR death_date IS NULL OR birth_date <= death_date) is correct. Recommend naming these constraints:

CONSTRAINT chk_person_birth_before_death 
    CHECK (birth_date IS NULL OR death_date IS NULL OR birth_date <= death_date),
CONSTRAINT chk_person_birth_date_precision_coherence 
    CHECK ((birth_date IS NULL) = (birth_date_precision = 'UNKNOWN'))

Named constraints produce readable error messages in Postgres (ERROR: new row for relation "persons" violates check constraint "chk_person_birth_before_death") instead of generic ones. This speeds up debugging when the constraint fires in production or tests.

Recommendations

  • Add PersonControllerTest case: PUT /api/persons/{id} with "birthDatePrecision": "INVALID_ENUM_VALUE" → expect 400, not 500.
  • Add PersonControllerTest case: PUT /api/persons/{id} with "birthDate": "not-a-date" → expect 400, not 500 with Jackson stack trace.
  • Name all CHECK constraints in the Flyway migration (see above) — makes production debugging materially faster.
  • The +page.server.ts action should surface the backend 400 as a localized form error, not a raw API error — verify this in PersonEditForm.svelte.test.ts.

Open Decisions (none)

## 🔒 Nora Steiner — Application Security Engineer ### Observations No authentication or authorization changes in this issue — the `@RequirePermission(Permission.WRITE_ALL)` pattern on person write endpoints already covers this feature, and I confirmed that `PersonController` uses it. No new `Permission` values are introduced. Focused review on input validation and enum security. **Three security findings:** **1. Jackson enum rejection — "confirm no lenient-enum config" is a manual step, not a test. This needs a test.** The issue correctly notes that Jackson rejects unknown enum values by default, and asks to "confirm no lenient-enum config exists." The correct fix is not just a check — it's an automated test. A `PersonControllerTest` case that sends `"birthDatePrecision": "BOGUS_VALUE"` to `PUT /api/persons/{id}` should return 400. Without this test, a future developer can accidentally add `@JsonProperty(access = WRITE_ONLY)` or configure `ALLOW_COERCION_OF_SCALARS` and silently break enum validation. Add this as a test case. **2. The `+page.server.ts` date parsing from German format (`dd.mm.yyyy → ISO`) is a new user-controlled string transformation that needs sanitization.** If someone enters `"../../etc/passwd"` or `"99.99.9999"` as a date, the server action must validate before forwarding to the backend. The backend will reject it via Jackson's `LocalDate` deserializer, but the error message returned to the browser must be a friendly i18n message, not Jackson's deserialization exception detail (which would leak implementation info). Explicitly test the malformed-input path: the form action must catch the backend 400 and surface a localized error string via `fail(400, { error: ... })`. **3. The DB CHECK constraints are the right defense layer — verify they are expressed as named constraints for error diagnosis.** `CHECK (birth_date IS NULL OR death_date IS NULL OR birth_date <= death_date)` is correct. Recommend naming these constraints: ```sql CONSTRAINT chk_person_birth_before_death CHECK (birth_date IS NULL OR death_date IS NULL OR birth_date <= death_date), CONSTRAINT chk_person_birth_date_precision_coherence CHECK ((birth_date IS NULL) = (birth_date_precision = 'UNKNOWN')) ``` Named constraints produce readable error messages in Postgres (`ERROR: new row for relation "persons" violates check constraint "chk_person_birth_before_death"`) instead of generic ones. This speeds up debugging when the constraint fires in production or tests. ### Recommendations - Add `PersonControllerTest` case: `PUT /api/persons/{id}` with `"birthDatePrecision": "INVALID_ENUM_VALUE"` → expect 400, not 500. - Add `PersonControllerTest` case: `PUT /api/persons/{id}` with `"birthDate": "not-a-date"` → expect 400, not 500 with Jackson stack trace. - Name all CHECK constraints in the Flyway migration (see above) — makes production debugging materially faster. - The `+page.server.ts` action should surface the backend 400 as a localized form error, not a raw API error — verify this in `PersonEditForm.svelte.test.ts`. ### Open Decisions _(none)_
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist

Observations

The test coverage plan in the issue is the most complete I've seen for a data model migration. Real Postgres via Testcontainers for migration tests — correct. @DataJpaTest for repository projection tests — correct. Multiple preferHumanDate edge cases called out explicitly. Good foundation.

Gaps I found after cross-referencing the test list against the codebase:

1. familyForest.test.ts — not listed in the test update list but will break.
At /home/marcel/Desktop/familienarchiv/frontend/src/lib/person/genealogy/layout/familyForest.test.ts lines 8–136, the test constructs PersonNodeDTO objects with birthYear directly. After the API regen, PersonNodeDTO still exposes birthYear/deathYear (per the issue's backward-compat decision), so these tests should still compile. However, the makePerson() factory function in those tests passes birthYear to a type-annotated PersonNodeDTO — verify the generated TypeScript type for PersonNodeDTO still includes birthYear as an optional integer after regen. If it does, familyForest.test.ts needs no changes. If the regen changes the field names, it breaks. Add to the "verify after regen" checklist.

2. Migration rollback test is absent.
The issue tests the forward migration thoroughly (backfill, constraints, column drop). But since Flyway migrations on Postgres are transactional, a failed migration rolls back the transaction. Add one test: introduce a row that would violate birth_date > death_date before the migration runs (pre-seed it as a data defect), and verify that either (a) the migration detects and corrects it before dropping columns, or (b) the constraint fires and the migration aborts cleanly. Currently the issue assumes the backfill UPDATE cannot create a birth_date > death_date row — but that is only safe if the existing birth_year > death_year data was already clean. The issue should add a data-quality pre-check step to the migration: SELECT COUNT(*) FROM persons WHERE birth_year > death_year — if non-zero, fail with a clear message rather than letting the CHECK constraint fail mid-migration.

3. PersonControllerTest currently tests birthYear in the JSON response (line 586 of the file). After migration, Person entity exposes birthDate/birthDatePrecision — the controller response shape changes. The issue lists this test file for update but the specific assertions about the JSON response shape need to be rewritten, not just the builder calls. Verify the acceptance criterion "Stammbaum unchanged" has a test: the response from GET /api/persons/stammbaum (or equivalent) must still contain birthYear as a number.

4. The APPROX precision is excluded from the person edit form (per the issue) but the personLifeDates.spec.ts must still test APPROX precision rendering — because existing persons imported with APPROX-precision dates (if any) should still render correctly in PersonCard and PersonHoverCard. The test plan says "one assertion per DatePrecision" — confirm APPROX is in that list.

Recommendations

  • Add explicit pre-check step to the V72 migration: DO $$ BEGIN IF EXISTS (SELECT 1 FROM persons WHERE birth_year IS NOT NULL AND death_year IS NOT NULL AND birth_year > death_year) THEN RAISE EXCEPTION 'Data quality issue: % persons have birth_year > death_year', (SELECT COUNT(*) FROM persons WHERE birth_year > death_year); END IF; END $$; — run before the backfill so the migration aborts early with a clear message rather than mid-migration.
  • Explicitly add familyForest.test.ts to the post-regen verification step (even if no changes needed — confirm it still compiles and passes).
  • Confirm APPROX precision is in personLifeDates.spec.ts test matrix, since formatDocumentDate handles it and person data might have APPROX from legacy imports.
  • The acceptance criterion for AC-7 ("all person list / search views continue to display birth/death years") needs a specific @DataJpaTest slice that calls PersonRepository.findAllWithDocumentCount() and asserts getBirthYear() returns a non-null Integer — not just a prose statement.
## 🧪 Sara Holt — QA Engineer & Test Strategist ### Observations The test coverage plan in the issue is the most complete I've seen for a data model migration. Real Postgres via Testcontainers for migration tests — correct. `@DataJpaTest` for repository projection tests — correct. Multiple `preferHumanDate` edge cases called out explicitly. Good foundation. **Gaps I found after cross-referencing the test list against the codebase:** **1. `familyForest.test.ts` — not listed in the test update list but will break.** At `/home/marcel/Desktop/familienarchiv/frontend/src/lib/person/genealogy/layout/familyForest.test.ts` lines 8–136, the test constructs `PersonNodeDTO` objects with `birthYear` directly. After the API regen, `PersonNodeDTO` still exposes `birthYear`/`deathYear` (per the issue's backward-compat decision), so these tests should still compile. However, the `makePerson()` factory function in those tests passes `birthYear` to a type-annotated `PersonNodeDTO` — verify the generated TypeScript type for `PersonNodeDTO` still includes `birthYear` as an optional integer after regen. If it does, `familyForest.test.ts` needs no changes. If the regen changes the field names, it breaks. Add to the "verify after regen" checklist. **2. Migration rollback test is absent.** The issue tests the forward migration thoroughly (backfill, constraints, column drop). But since Flyway migrations on Postgres are transactional, a failed migration rolls back the transaction. Add one test: introduce a row that would violate `birth_date > death_date` before the migration runs (pre-seed it as a data defect), and verify that either (a) the migration detects and corrects it before dropping columns, or (b) the constraint fires and the migration aborts cleanly. Currently the issue assumes the backfill `UPDATE` cannot create a `birth_date > death_date` row — but that is only safe if the existing `birth_year > death_year` data was already clean. The issue should add a data-quality pre-check step to the migration: `SELECT COUNT(*) FROM persons WHERE birth_year > death_year` — if non-zero, fail with a clear message rather than letting the CHECK constraint fail mid-migration. **3. `PersonControllerTest` currently tests `birthYear` in the JSON response (line 586 of the file). After migration, `Person` entity exposes `birthDate`/`birthDatePrecision` — the controller response shape changes. The issue lists this test file for update but the specific assertions about the JSON response shape need to be rewritten, not just the builder calls. Verify the acceptance criterion "Stammbaum unchanged" has a test: the response from `GET /api/persons/stammbaum` (or equivalent) must still contain `birthYear` as a number.** **4. The `APPROX` precision is excluded from the person edit form (per the issue) but the `personLifeDates.spec.ts` must still test `APPROX` precision rendering — because existing persons imported with APPROX-precision dates (if any) should still render correctly in `PersonCard` and `PersonHoverCard`. The test plan says "one assertion per DatePrecision" — confirm `APPROX` is in that list.** ### Recommendations - Add explicit pre-check step to the V72 migration: `DO $$ BEGIN IF EXISTS (SELECT 1 FROM persons WHERE birth_year IS NOT NULL AND death_year IS NOT NULL AND birth_year > death_year) THEN RAISE EXCEPTION 'Data quality issue: % persons have birth_year > death_year', (SELECT COUNT(*) FROM persons WHERE birth_year > death_year); END IF; END $$;` — run before the backfill so the migration aborts early with a clear message rather than mid-migration. - Explicitly add `familyForest.test.ts` to the post-regen verification step (even if no changes needed — confirm it still compiles and passes). - Confirm `APPROX` precision is in `personLifeDates.spec.ts` test matrix, since `formatDocumentDate` handles it and person data might have `APPROX` from legacy imports. - The acceptance criterion for AC-7 ("all person list / search views continue to display birth/death years") needs a specific `@DataJpaTest` slice that calls `PersonRepository.findAllWithDocumentCount()` and asserts `getBirthYear()` returns a non-null `Integer` — not just a prose statement.
Author
Owner

🎨 Leonie Voss — UI/UX Design Lead

Observations

The issue's UX spec is notably well-considered for a data model migration. The form design specifics (DAY/MONTH/YEAR only, helper text, touch targets, whitespace-normal in dropdown) reflect real user needs. I'm reviewing from the 60+ transcriber audience perspective — this is their data entry path.

Four UX concerns after inspecting the current PersonEditForm.svelte (lines 92–113):

1. The current form has two simple number inputs for birth/death year. Replacing these with 4 controls (2 date inputs + 2 precision selects) doubles the field count. On a narrow screen this creates significant form weight.

The issue correctly says "group visually in the existing card/section pattern — one card for birth, one for death." I recommend an explicit layout decision: the date input and its precision select should sit on one row within each card using a flex layout (flex gap-3 items-start). This way:

  • Mobile (375px): date input full-width on top row, precision select on second row
  • Desktop: side by side

This prevents the 4 controls from stacking into 4 separate rows on mobile, which creates a long scroll and cognitive mismatch between "birth" and "death" sections.

2. The precision select helper text "Wie genau ist dieses Datum bekannt?" is good, but three options (Tag / Monat / Jahr) without visual differentiation may confuse seniors.

Recommend making the select a visually distinct block with a <fieldset> + <legend> instead of a <label> + <select>:

<fieldset>
  <legend class="text-sm font-sans text-ink-2">Wie genau ist dieses Datum bekannt?</legend>
  <select name="birthDatePrecision">
    <option value="DAY">Genaues Datum (Tag)</option>
    <option value="MONTH">Monat bekannt</option>
    <option value="YEAR">Nur Jahreszahl</option>
  </select>
</fieldset>

The option labels should be descriptive German phrases, not raw enum names. "DAY", "MONTH", "YEAR" displayed as-is would fail the 60+ audience immediately.

3. Empty-state behavior when a person has neither date nor precision.
The issue correctly specifies: render nothing (not an empty * –). Verify this is also true for the case where only birthDate is set but deathDate is null — the output should be * 14. März 1901 (birth only, no dash, no ). The current personLifeDates.ts code handles this correctly for year-only, but confirm the new 5-param signature does too.

4. MentionDropdown.svelte whitespace-normal verification is listed but needs a specific visual scenario.
The dropdown is a narrow column. A DAY-precision date like * 14. März 1901 – † 2. November 1944 is ~37 chars. In a narrow autocomplete dropdown (approx 220–280px wide at typical screen sizes), this will wrap to 2 lines. That's fine, but the row height must increase to accommodate wrapping — confirm the dropdown row has min-height or py padding that doesn't clip the second line. The issue says "verify whitespace-normal" — I'd add "and confirm overflow: visible on the row container."

Recommendations

  • Define the 2-control row layout for the date + precision pair explicitly: flex flex-col sm:flex-row gap-2 ensures mobile stacks, desktop is inline.
  • Use descriptive <option> label text in German (not raw enum values) — "Genaues Datum", "Nur Monat bekannt", "Nur Jahreszahl" — and add a <legend> to the precision group.
  • Test the MentionDropdown at 320px viewport width with a DAY-precision person to confirm row height expansion works before merging.
  • Add a micro-copy note below each date section in the form: "Leer lassen, wenn unbekannt" — seniors need explicit guidance that empty is valid, otherwise they fill "0" in number fields (current behavior pattern).

Open Decisions (none)

## 🎨 Leonie Voss — UI/UX Design Lead ### Observations The issue's UX spec is notably well-considered for a data model migration. The form design specifics (DAY/MONTH/YEAR only, helper text, touch targets, `whitespace-normal` in dropdown) reflect real user needs. I'm reviewing from the 60+ transcriber audience perspective — this is their data entry path. **Four UX concerns after inspecting the current `PersonEditForm.svelte` (lines 92–113):** **1. The current form has two simple number inputs for birth/death year. Replacing these with 4 controls (2 date inputs + 2 precision selects) doubles the field count. On a narrow screen this creates significant form weight.** The issue correctly says "group visually in the existing card/section pattern — one card for birth, one for death." I recommend an explicit layout decision: the date input and its precision select should sit on **one row** within each card using a flex layout (`flex gap-3 items-start`). This way: - Mobile (375px): date input full-width on top row, precision select on second row - Desktop: side by side This prevents the 4 controls from stacking into 4 separate rows on mobile, which creates a long scroll and cognitive mismatch between "birth" and "death" sections. **2. The precision select helper text "Wie genau ist dieses Datum bekannt?" is good, but three options (Tag / Monat / Jahr) without visual differentiation may confuse seniors.** Recommend making the select a visually distinct block with a `<fieldset>` + `<legend>` instead of a `<label>` + `<select>`: ```html <fieldset> <legend class="text-sm font-sans text-ink-2">Wie genau ist dieses Datum bekannt?</legend> <select name="birthDatePrecision"> <option value="DAY">Genaues Datum (Tag)</option> <option value="MONTH">Monat bekannt</option> <option value="YEAR">Nur Jahreszahl</option> </select> </fieldset> ``` The option labels should be descriptive German phrases, not raw enum names. "DAY", "MONTH", "YEAR" displayed as-is would fail the 60+ audience immediately. **3. Empty-state behavior when a person has neither date nor precision.** The issue correctly specifies: render nothing (not an empty `* –`). Verify this is also true for the case where only `birthDate` is set but `deathDate` is null — the output should be `* 14. März 1901` (birth only, no dash, no `†`). The current `personLifeDates.ts` code handles this correctly for year-only, but confirm the new 5-param signature does too. **4. `MentionDropdown.svelte` whitespace-normal verification is listed but needs a specific visual scenario.** The dropdown is a narrow column. A DAY-precision date like `* 14. März 1901 – † 2. November 1944` is ~37 chars. In a narrow autocomplete dropdown (approx 220–280px wide at typical screen sizes), this will wrap to 2 lines. That's fine, but the row height must increase to accommodate wrapping — confirm the dropdown row has `min-height` or `py` padding that doesn't clip the second line. The issue says "verify `whitespace-normal`" — I'd add "and confirm `overflow: visible` on the row container." ### Recommendations - Define the 2-control row layout for the date + precision pair explicitly: `flex flex-col sm:flex-row gap-2` ensures mobile stacks, desktop is inline. - Use descriptive `<option>` label text in German (not raw enum values) — "Genaues Datum", "Nur Monat bekannt", "Nur Jahreszahl" — and add a `<legend>` to the precision group. - Test the MentionDropdown at 320px viewport width with a DAY-precision person to confirm row height expansion works before merging. - Add a micro-copy note below each date section in the form: "Leer lassen, wenn unbekannt" — seniors need explicit guidance that empty is valid, otherwise they fill "0" in number fields (current behavior pattern). ### Open Decisions _(none)_
Author
Owner

🚀 Tobias Wendt — DevOps & Platform Engineer

Observations

Migration V72 confirmed as next in sequence — V71 is the current latest (V71__person_delete_on_delete_fk.sql). Good. The issue explicitly calls this out, which prevents the collision risk when parallel branches are in flight. One Flyway migration file, atomic in Postgres — correct for a change of this scope.

Three DevOps/infrastructure observations:

1. The deploy runbook note is listed as a task ("after V72, the first canonical re-import must be spot-checked") but there is no rollback procedure.
If V72 runs in production and a data problem is discovered post-deployment (e.g., a person whose year data was inconsistent and the backfill produced a wrong date), there is no path forward. The columns are dropped in the same migration. Document the recovery procedure in the runbook note: a partial restore from the pre-migration backup (the nightly pg_dump) is the only path to recover dropped columns. This is a one-way migration — say so explicitly in the runbook note and confirm a backup was taken immediately before the deploy.

2. CI: the Testcontainers migration test will run postgres:16-alpine — good. But the existing RenameUsersToAppUsersMigrationTest.java sets the precedent for migration-specific tests. Confirm the new V72MigrationTest follows the same pattern (Testcontainers, not H2, not @SpringBootTest full context). Full @SpringBootTest runs all migrations but doesn't let you inspect intermediate state. A dedicated migration test class with @DataJpaTest or a raw JdbcTemplate probe is cleaner for verifying column-specific states.

3. The Testcontainers postgres:16-alpine image should be pinned to the same digest used in existing tests to ensure consistency.
Check /home/marcel/Desktop/familienarchiv/backend/src/test/java/org/raddatz/familienarchiv/user/RenameUsersToAppUsersMigrationTest.java for the exact image tag used — if it's postgres:16-alpine (unpinned), that's consistent with project convention. Confirm V72 test uses the same string, not postgres:latest or a different minor version.

Recommendations

  • Add a one-sentence rollback procedure to the deploy runbook note: "If post-deploy data issues are found, restore persons table from pre-migration backup; V72 cannot be rolled back via Flyway (columns are dropped)."
  • Confirm the nightly pg_dump backup runs before the deploy window — check the cron schedule in docs/infrastructure/ and explicitly note it in the runbook.
  • The "GROUP BY path test" (exercising the multi-document-persons query with GROUP BY p.birth_date, p.birth_date_precision, ...) should run as a @DataJpaTest slice with the real PersonRepository against a Testcontainers postgres — not a unit test with mocked SQL. This is the class of bug that only surfaces on real Postgres with a real schema.

Open Decisions (none)

## 🚀 Tobias Wendt — DevOps & Platform Engineer ### Observations Migration V72 confirmed as next in sequence — V71 is the current latest (`V71__person_delete_on_delete_fk.sql`). Good. The issue explicitly calls this out, which prevents the collision risk when parallel branches are in flight. One Flyway migration file, atomic in Postgres — correct for a change of this scope. **Three DevOps/infrastructure observations:** **1. The deploy runbook note is listed as a task ("after V72, the first canonical re-import must be spot-checked") but there is no rollback procedure.** If V72 runs in production and a data problem is discovered post-deployment (e.g., a person whose year data was inconsistent and the backfill produced a wrong date), there is no path forward. The columns are dropped in the same migration. Document the recovery procedure in the runbook note: a partial restore from the pre-migration backup (the nightly `pg_dump`) is the only path to recover dropped columns. This is a one-way migration — say so explicitly in the runbook note and confirm a backup was taken immediately before the deploy. **2. CI: the Testcontainers migration test will run `postgres:16-alpine` — good. But the existing `RenameUsersToAppUsersMigrationTest.java` sets the precedent for migration-specific tests. Confirm the new `V72MigrationTest` follows the same pattern (Testcontainers, not H2, not `@SpringBootTest` full context). Full `@SpringBootTest` runs all migrations but doesn't let you inspect intermediate state. A dedicated migration test class with `@DataJpaTest` or a raw `JdbcTemplate` probe is cleaner for verifying column-specific states.** **3. The Testcontainers `postgres:16-alpine` image should be pinned to the same digest used in existing tests to ensure consistency.** Check `/home/marcel/Desktop/familienarchiv/backend/src/test/java/org/raddatz/familienarchiv/user/RenameUsersToAppUsersMigrationTest.java` for the exact image tag used — if it's `postgres:16-alpine` (unpinned), that's consistent with project convention. Confirm V72 test uses the same string, not `postgres:latest` or a different minor version. ### Recommendations - Add a one-sentence rollback procedure to the deploy runbook note: "If post-deploy data issues are found, restore `persons` table from pre-migration backup; V72 cannot be rolled back via Flyway (columns are dropped)." - Confirm the nightly `pg_dump` backup runs before the deploy window — check the cron schedule in `docs/infrastructure/` and explicitly note it in the runbook. - The "GROUP BY path test" (exercising the multi-document-persons query with `GROUP BY p.birth_date, p.birth_date_precision, ...`) should run as a `@DataJpaTest` slice with the real `PersonRepository` against a Testcontainers postgres — not a unit test with mocked SQL. This is the class of bug that only surfaces on real Postgres with a real schema. ### Open Decisions _(none)_
Author
Owner

📋 Elicit — Requirements Engineer

Observations

This is an exceptionally well-specified issue. The resolved decisions table, the EARS-style requirement (REQ-PERSON-DATE-01), and the explicit scope boundary on PersonRelationship.fromYear are all hallmarks of a ready-to-implement spec. The acceptance criteria are testable. Almost all the ambiguities I'd normally flag are already resolved.

Two requirements-level gaps I found:

1. AC-4 ("equal dates are allowed") is not reflected in the service pseudocode — but it is in the validation method.
birthDate.isAfter(deathDate) correctly allows equal dates (a person born and died on the same day — unusual but historically documented, e.g., stillbirths). Good. However, the acceptance criterion does not address the case where birthDate has DAY precision and deathDate has YEAR precision pointing to the same calendar year but a day before the birth. Example: birthDate = 1901-11-15 (DAY), deathDate = 1901-01-01 (YEAR). The LocalDate comparison 1901-11-15.isAfter(1901-01-01) is true → rejected. But the intent of deathDate = 1901 (YEAR precision) might be "died sometime in 1901, unknown day." The current approach rejects this as a birth-after-death error, which is arguably false — the person could have died in November 1901 after being born. This is a known limitation that should be called out explicitly rather than silently producing a validation error that confuses the transcriber ("but they died in 1901 and were born in November 1901!").

Recommendation: add a note in the error message or the spec: "When comparing DAY-precision birth against YEAR-precision death (or vice versa), the comparison uses the stored LocalDate as-is (1901-01-01 for a YEAR-precision 1901 death). Transcribers should enter 1902 for the death year if they only know the person died after a November 1901 birthday."

2. The i18n scope is underspecified for the new form controls.
The issue says "add i18n keys in messages/{de,en,es}.json" but does not list the required key names. For a 3-language project with a solo developer, missing key names is a common source of inconsistency (German gets full text, English/Spanish get an empty string or a German fallback). At minimum, define the key names for:

  • person_label_birth_date / person_label_death_date (replacing person_label_birth_year / person_label_death_year)
  • person_label_birth_date_precision / person_label_death_date_precision
  • person_precision_hint ("Wie genau ist dieses Datum bekannt?")
  • person_precision_day / person_precision_month / person_precision_year (option labels in the select)
  • person_date_placeholder ("leer lassen, wenn unbekannt")
  • Error message keys for BIRTH_AFTER_DEATH and INVALID_DATE_PRECISION

Without explicit key names, the developer has to invent them during implementation, creating drift between locales.

Recommendations

  • Add a callout box or note to the spec: "Mixed-precision birth/death comparisons use the stored LocalDate directly. The YEAR-precision date 1901-01-01 may cause false rejection when compared against a later DAY-precision date in 1901. Transcribers should use the next calendar year if they only know the death year is the same as a late-in-year birthday."
  • Add a key inventory table for all new i18n keys (de/en/es required, list de text inline, mark en/es as TBD-translate).
  • The "Update tests" task list includes persons/page.svelte.test.ts — confirm this file actually exists; a missing test file in the task list creates confusion during implementation.

Open Decisions (none — all key decisions resolved in the spec)

## 📋 Elicit — Requirements Engineer ### Observations This is an exceptionally well-specified issue. The resolved decisions table, the EARS-style requirement (`REQ-PERSON-DATE-01`), and the explicit scope boundary on `PersonRelationship.fromYear` are all hallmarks of a ready-to-implement spec. The acceptance criteria are testable. Almost all the ambiguities I'd normally flag are already resolved. **Two requirements-level gaps I found:** **1. AC-4 ("equal dates are allowed") is not reflected in the service pseudocode — but it is in the validation method.** `birthDate.isAfter(deathDate)` correctly allows equal dates (a person born and died on the same day — unusual but historically documented, e.g., stillbirths). Good. However, the acceptance criterion does not address the case where `birthDate` has `DAY` precision and `deathDate` has `YEAR` precision pointing to the same calendar year but a day *before* the birth. Example: `birthDate = 1901-11-15 (DAY)`, `deathDate = 1901-01-01 (YEAR)`. The `LocalDate` comparison `1901-11-15.isAfter(1901-01-01)` is `true` → rejected. But the intent of `deathDate = 1901 (YEAR precision)` might be "died sometime in 1901, unknown day." The current approach rejects this as a birth-after-death error, which is arguably false — the person could have died in November 1901 after being born. **This is a known limitation that should be called out explicitly** rather than silently producing a validation error that confuses the transcriber ("but they died in 1901 and were born in November 1901!"). Recommendation: add a note in the error message or the spec: "When comparing `DAY`-precision birth against `YEAR`-precision death (or vice versa), the comparison uses the stored `LocalDate` as-is (`1901-01-01` for a YEAR-precision 1901 death). Transcribers should enter `1902` for the death year if they only know the person died after a November 1901 birthday." **2. The i18n scope is underspecified for the new form controls.** The issue says "add i18n keys in `messages/{de,en,es}.json`" but does not list the required key names. For a 3-language project with a solo developer, missing key names is a common source of inconsistency (German gets full text, English/Spanish get an empty string or a German fallback). At minimum, define the key names for: - `person_label_birth_date` / `person_label_death_date` (replacing `person_label_birth_year` / `person_label_death_year`) - `person_label_birth_date_precision` / `person_label_death_date_precision` - `person_precision_hint` ("Wie genau ist dieses Datum bekannt?") - `person_precision_day` / `person_precision_month` / `person_precision_year` (option labels in the select) - `person_date_placeholder` ("leer lassen, wenn unbekannt") - Error message keys for `BIRTH_AFTER_DEATH` and `INVALID_DATE_PRECISION` Without explicit key names, the developer has to invent them during implementation, creating drift between locales. ### Recommendations - Add a callout box or note to the spec: "Mixed-precision birth/death comparisons use the stored `LocalDate` directly. The `YEAR`-precision date `1901-01-01` may cause false rejection when compared against a later `DAY`-precision date in 1901. Transcribers should use the next calendar year if they only know the death year is the same as a late-in-year birthday." - Add a key inventory table for all new i18n keys (de/en/es required, list de text inline, mark en/es as TBD-translate). - The "Update tests" task list includes `persons/page.svelte.test.ts` — confirm this file actually exists; a missing test file in the task list creates confusion during implementation. ### Open Decisions _(none — all key decisions resolved in the spec)_
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#773