feat(person): migrate birth/death year to LocalDate + DatePrecision (#773) #812

Merged
marcel merged 16 commits from feat/issue-773-person-birth-death-localdate into main 2026-06-12 21:49:17 +02:00
Owner

Closes #773.

Implements the full spec from #773: Person.birthYear/deathYear (Integer) become birthDate/deathDate (LocalDate) + NOT NULL DatePrecision columns mirroring Document.metaDatePrecision.

Commits

  1. feat(person): add BIRTH_AFTER_DEATH and INVALID_DATE_PRECISION error codes — backend enum + frontend mirror + i18n (de/en/es, incl. mixed-precision workaround hint)
  2. feat(person): V76 migration — pre-check abort gates, YYYY-01-01/YEAR backfill, named CHECK constraints, column drop; staged-Flyway Testcontainers tests (7)
  3. feat(person): store birth/death as LocalDate + DatePrecision — entity, PersonUpdateDTO, validateLifeDates, preferHumanDate (DatePrecisionPair), native query EXTRACT(YEAR …) projections, null-safe PersonNodeDTO/RelationshipDTO years
  4. chore(api): regenerate TypeScript types
  5. feat(person): formatLifeDateRange takes date + precision — delegates to formatDocumentDate; new glyph-free formatLifeDate helper
  6. feat(person): render precise life dates on cards, hover card, and mention dropdown — aria-hidden * / † glyphs, empty-state guards, 320px wrap
  7. feat(person): date + precision controls on person new/edit forms — new PersonLifeDateField (German date input + hidden ISO + DAY/MONTH/YEAR select, min-h-[44px])
  8. docs(person): ADR-039, DB diagrams, V76 deploy runbook note
  9. test(person): add now-required precision fields to Person test fixtures

Deviations from the issue text (each deliberate)

  • badRequest() → 400, not conflict() → 409 for BIRTH_AFTER_DEATH/INVALID_DATE_PRECISION: the spec's code sample said conflict(), but acceptance criteria #4/#10 require 400 and the document domain's INVALID_DATE_RANGE uses badRequest(). Confirmed with Marcel before implementation.
  • 5 named CHECK constraints, not 6: the issue's "same three for death" double-counts the cross-field chk_person_birth_before_death, which exists once.
  • Jackson grep is not empty: the only DeserializationFeature hit in src/main is RestClientOcrClient's private ObjectMapper (OCR HTTP client, sets FAIL_ON_UNKNOWN_PROPERTIES=true) — the Spring MVC mapper has no enum-leniency override, so unknown enum values still 400. Documented in PersonControllerTest.
  • 4th formatLifeDateRange caller found and updated: routes/persons/[id]/PersonCard.svelte (not listed in the issue).
  • Only searchWithDocumentCount had a GROUP BY among the four summary queries — updated to the four new columns; the other three never grouped.
  • persons_tree.py unchanged — it already emits year-only (_parse_year); its 57 tests pass untouched, matching the issue's test section.
  • Cards compose glyphs in markup (aria-hidden spans + formatLifeDate) while formatLifeDateRange keeps glyphs for plain-text contexts (MentionDropdown subtitle) — the issue required both behaviors.
  • Legacy APPROX in the edit form seeds the precision select as YEAR (not DAY) so an untouched save never silently claims day precision.

Verification

  • Backend: migration test (7), PersonServiceTest (82), PersonControllerTest (68), PersonImportUpsertTest (15), PersonRepositoryTest (59, incl. 3 new projection tests), PersonServiceIntegrationTest (19), importer + relationship + canonical-import suites — all green; mvnw clean package -DskipTests builds.
  • Frontend: personLifeDates.spec (21), card/hover/mention/form browser suites incl. 320px layout + APPROX render tests — all green; svelte-check at 795 errors (below the ~834 pre-existing baseline; remaining birthDatePrecision mentions are in fixtures that were already type-broken before this branch).
  • Python: test_persons_tree.py 57 passed, unchanged.

Deploy

V76 is one-way. Take a manual pg_dump before deploying (no automated nightly backup exists yet — runbook note added to docs/DEPLOYMENT.md §5 with the targeted pg_restore -t persons rollback).

🤖 Generated with Claude Code

Closes #773. Implements the full spec from #773: `Person.birthYear/deathYear` (Integer) become `birthDate/deathDate` (`LocalDate`) + NOT NULL `DatePrecision` columns mirroring `Document.metaDatePrecision`. ## Commits 1. `feat(person): add BIRTH_AFTER_DEATH and INVALID_DATE_PRECISION error codes` — backend enum + frontend mirror + i18n (de/en/es, incl. mixed-precision workaround hint) 2. `feat(person): V76 migration` — pre-check abort gates, `YYYY-01-01`/`YEAR` backfill, named CHECK constraints, column drop; staged-Flyway Testcontainers tests (7) 3. `feat(person): store birth/death as LocalDate + DatePrecision` — entity, `PersonUpdateDTO`, `validateLifeDates`, `preferHumanDate` (`DatePrecisionPair`), native query `EXTRACT(YEAR …)` projections, null-safe `PersonNodeDTO`/`RelationshipDTO` years 4. `chore(api): regenerate TypeScript types` 5. `feat(person): formatLifeDateRange takes date + precision` — delegates to `formatDocumentDate`; new glyph-free `formatLifeDate` helper 6. `feat(person): render precise life dates on cards, hover card, and mention dropdown` — aria-hidden `* / †` glyphs, empty-state guards, 320px wrap 7. `feat(person): date + precision controls on person new/edit forms` — new `PersonLifeDateField` (German date input + hidden ISO + DAY/MONTH/YEAR select, `min-h-[44px]`) 8. `docs(person): ADR-039, DB diagrams, V76 deploy runbook note` 9. `test(person): add now-required precision fields to Person test fixtures` ## Deviations from the issue text (each deliberate) - **`badRequest()` → 400, not `conflict()` → 409** for `BIRTH_AFTER_DEATH`/`INVALID_DATE_PRECISION`: the spec's code sample said `conflict()`, but acceptance criteria #4/#10 require 400 and the document domain's `INVALID_DATE_RANGE` uses `badRequest()`. Confirmed with Marcel before implementation. - **5 named CHECK constraints, not 6**: the issue's "same three for death" double-counts the cross-field `chk_person_birth_before_death`, which exists once. - **Jackson grep is not empty**: the only `DeserializationFeature` hit in `src/main` is `RestClientOcrClient`'s private ObjectMapper (OCR HTTP client, sets `FAIL_ON_UNKNOWN_PROPERTIES=true`) — the Spring MVC mapper has no enum-leniency override, so unknown enum values still 400. Documented in `PersonControllerTest`. - **4th `formatLifeDateRange` caller** found and updated: `routes/persons/[id]/PersonCard.svelte` (not listed in the issue). - **Only `searchWithDocumentCount` had a GROUP BY** among the four summary queries — updated to the four new columns; the other three never grouped. - **`persons_tree.py` unchanged** — it already emits year-only (`_parse_year`); its 57 tests pass untouched, matching the issue's test section. - **Cards compose glyphs in markup** (aria-hidden spans + `formatLifeDate`) while `formatLifeDateRange` keeps glyphs for plain-text contexts (MentionDropdown subtitle) — the issue required both behaviors. - **Legacy APPROX in the edit form** seeds the precision select as YEAR (not DAY) so an untouched save never silently claims day precision. ## Verification - Backend: migration test (7), `PersonServiceTest` (82), `PersonControllerTest` (68), `PersonImportUpsertTest` (15), `PersonRepositoryTest` (59, incl. 3 new projection tests), `PersonServiceIntegrationTest` (19), importer + relationship + canonical-import suites — all green; `mvnw clean package -DskipTests` builds. - Frontend: `personLifeDates.spec` (21), card/hover/mention/form browser suites incl. 320px layout + APPROX render tests — all green; `svelte-check` at 795 errors (below the ~834 pre-existing baseline; remaining `birthDatePrecision` mentions are in fixtures that were already type-broken before this branch). - Python: `test_persons_tree.py` 57 passed, unchanged. ## Deploy V76 is one-way. Take a manual `pg_dump` before deploying (no automated nightly backup exists yet — runbook note added to `docs/DEPLOYMENT.md` §5 with the targeted `pg_restore -t persons` rollback). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 9 commits 2026-06-12 18:29:01 +02:00
Backend enum, frontend ErrorCode mirror, getErrorMessage cases, and
error message i18n keys (de/en/es) incl. the mixed-precision workaround
hint in error_birth_after_death.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pre-check aborts on corrupt year data, backfills YYYY-01-01/YEAR,
adds five named CHECK constraints, drops birth_year/death_year.
Staged-Flyway Testcontainers test covers pre-check aborts, backfill
shapes, and post-migration schema.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Entity swap mirroring Document.metaDatePrecision; PersonUpdateDTO takes
date + precision; validateLifeDates (badRequest BIRTH_AFTER_DEATH /
INVALID_DATE_PRECISION) replaces validateYears; preferHumanDate keeps
DAY/MONTH/SEASON hand-entered dates on re-import and refreshes
YEAR/UNKNOWN from the canonical year (ADR-025 extension);
PersonUpsertCommand stays year-shaped. Native queries project
EXTRACT(YEAR ...) so PersonSummaryDTO and PersonNodeDTO stay
year-shaped, null-safe for undated persons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Person gains birthDate/deathDate + required precision enums;
PersonSummaryDTO, PersonNodeDTO, and RelationshipDTO keep derived
integer years. familyForest/buildLayout tests still pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New formatLifeDate single-date helper carries no glyph so cards can wrap
* / † in aria-hidden spans. Missing precision falls back to YEAR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cards compose aria-hidden * / † glyphs in markup so screen readers only
announce the dates; PersonSummaryDTO list card stays year-shaped by
design (ADR-039). MentionDropdown subtitle wraps instead of truncating
so DAY-precision ranges fit at 320px.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New PersonLifeDateField (German date input + hidden ISO + DAY/MONTH/YEAR
precision select, min-h-44px, sm: side-by-side) used for birth and death
in both forms. Legacy APPROX precision seeds the select as YEAR so an
untouched save never claims DAY. Server actions send date+precision
pairs or omit both; obsolete year i18n keys removed, 9 form keys added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
test(person): add now-required precision fields to Person test fixtures
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m32s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 4m39s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
f2f9006548
birthDatePrecision/deathDatePrecision are @Schema REQUIRED, so the
generated Person type makes them non-optional — fixtures that were
type-clean before the regen get UNKNOWN defaults.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
marcel added 7 commits 2026-06-12 19:37:50 +02:00
The mention dropdown renders precise life dates but receives
PersonSummaryDTO items from /api/persons, which only carried the derived
years - the date fields were silently undefined at runtime. Add
birth/death date + precision to the projection and all four native
queries (searchWithDocumentCount's GROUP BY already listed the columns).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The dropdown and editor typed /api/persons list items as the full Person
entity. The actual wire shape is PersonSummaryDTO, which until the
previous commit had no date fields - so the life-date subtitle rendered
blank in production while fixtures (built from the entity type) kept the
tests green. Retype items as the summary projection and guard the two
personId consumers against the schema-optional id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A partial date (e.g. "14.03.") left the hidden ISO input empty, so
saving the edit form silently cleared a stored date. PersonLifeDateField
now delegates to the shared DateInput primitive (inline format error,
calendar validation) and sets a custom validity while the error is
present, so the browser blocks native submission for both person forms.
A full clear stays submittable - that is the intentional clear path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The canonical upsert path skips validateLifeDates, so a spreadsheet row
with birth_year > death_year - or a preserved hand-entered birth date
conflicting with a canonical death year - violated the V76 CHECK
constraint at flush time and aborted the whole import batch with a raw
500. Resolve the pairs first and, on conflict, keep the person's stored
life dates (empty for a new person), drop the canonical refresh, and log
a WARN with the sourceRef (REQ-IMP-001: never abort the batch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
docs(person): note YEAR seeding of legacy precisions in ADR-039
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m47s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 5m12s
CI / fail2ban Regex (pull_request) Successful in 52s
CI / Semgrep Security Scan (pull_request) Successful in 28s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m10s
2fac687318
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

Review findings addressed in 7 follow-up commits (f2f90065..2fac6873):

🔴 MentionDropdown life dates blank at runtime — fixed in two commits:

  • 92672db exposes birthDate/birthDatePrecision/deathDate/deathDatePrecision on the PersonSummaryDTO projection and all four native queries (+4 Testcontainers projection tests, red→green).
  • 82af906 retypes the mention dropdown/editor items as PersonSummaryDTO (the actual wire shape), regenerates the API types from a throwaway-DB backend run (dev DB stays at V74, untouched), and guards the two personId consumers against the schema-optional id.

🟡 Silent date clear on partial input36a7fd6: PersonLifeDateField now delegates to the shared DateInput primitive (inline form_date_error, calendar validation) and sets a custom validity while the error is present, so the browser blocks native submission on both person forms. Full clear stays submittable (intentional clear path). New PersonLifeDateField.svelte.spec.ts (4 tests) + 1 edit-form test.

🟡 Import 500 on birth>death conflictef7d0f2: the canonical upsert resolves both DatePrecisionPairs and, on conflict, keeps the stored life dates (empty for a new person), drops the canonical refresh, and WARNs with the sourceRef — REQ-IMP-001, never aborts the batch, hand-entered dates survive by construction. 3 new Mockito tests.

Nits2a9ae80 shares yearOf between the relationship services, 11efb44 adds the trailing period to error_invalid_date_precision (de/en/es), 2fac687 documents the YEAR seeding of legacy precisions in ADR-039.

Verification: PersonRepositoryTest (63), PersonControllerTest (68), PersonImportUpsertTest (18), PersonServiceTest (82), relationship suites (31) all green; mention/dropdown/editor + life-date-field + edit-form + DateInput + persons/new browser suites (147 tests across runs) all green; svelte-check clean for all touched files.

🤖 Generated with Claude Code

Review findings addressed in 7 follow-up commits (f2f90065..2fac6873): **🔴 MentionDropdown life dates blank at runtime** — fixed in two commits: - `92672db` exposes `birthDate`/`birthDatePrecision`/`deathDate`/`deathDatePrecision` on the `PersonSummaryDTO` projection and all four native queries (+4 Testcontainers projection tests, red→green). - `82af906` retypes the mention dropdown/editor items as `PersonSummaryDTO` (the actual wire shape), regenerates the API types from a throwaway-DB backend run (dev DB stays at V74, untouched), and guards the two `personId` consumers against the schema-optional `id`. **🟡 Silent date clear on partial input** — `36a7fd6`: `PersonLifeDateField` now delegates to the shared `DateInput` primitive (inline `form_date_error`, calendar validation) and sets a custom validity while the error is present, so the browser blocks native submission on both person forms. Full clear stays submittable (intentional clear path). New `PersonLifeDateField.svelte.spec.ts` (4 tests) + 1 edit-form test. **🟡 Import 500 on birth>death conflict** — `ef7d0f2`: the canonical upsert resolves both `DatePrecisionPair`s and, on conflict, keeps the stored life dates (empty for a new person), drops the canonical refresh, and WARNs with the sourceRef — REQ-IMP-001, never aborts the batch, hand-entered dates survive by construction. 3 new Mockito tests. **Nits** — `2a9ae80` shares `yearOf` between the relationship services, `11efb44` adds the trailing period to `error_invalid_date_precision` (de/en/es), `2fac687` documents the YEAR seeding of legacy precisions in ADR-039. Verification: `PersonRepositoryTest` (63), `PersonControllerTest` (68), `PersonImportUpsertTest` (18), `PersonServiceTest` (82), relationship suites (31) all green; mention/dropdown/editor + life-date-field + edit-form + DateInput + persons/new browser suites (147 tests across runs) all green; svelte-check clean for all touched files. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel merged commit 75e48f2922 into main 2026-06-12 21:49:17 +02:00
marcel deleted branch feat/issue-773-person-birth-death-localdate 2026-06-12 21:49:18 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#812