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
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
99 lines
5.1 KiB
Markdown
99 lines
5.1 KiB
Markdown
# ADR-039 — Person life dates become LocalDate + DatePrecision
|
|
|
|
**Status:** Accepted
|
|
**Date:** 2026-06-12
|
|
**Issue:** #773 (Zeitstrahl milestone, foundational)
|
|
|
|
## Context
|
|
|
|
`Person` stored `birthYear`/`deathYear` as `Integer`. A known exact birthday
|
|
(`1901-03-14`) had nowhere to live, and every display was stuck at year precision.
|
|
The Zeitstrahl's derived life-events need real dates with precision metadata, and the
|
|
document domain already solved exactly this problem with
|
|
`Document.documentDate` + `metaDatePrecision`.
|
|
|
|
V76 replaces the two integer columns with `birth_date`/`death_date` (`DATE`, nullable)
|
|
plus `birth_date_precision`/`death_date_precision` (`VARCHAR(16) NOT NULL DEFAULT
|
|
'UNKNOWN'`), backfilling existing years as `YYYY-01-01` at `YEAR` precision.
|
|
|
|
## Decisions
|
|
|
|
### 1. `DatePrecision` stays in `document/` and is imported cross-domain
|
|
|
|
The `person` package imports `org.raddatz.familienarchiv.document.DatePrecision`
|
|
directly. The layering rule (controllers → services → own repository) governs
|
|
service-to-repository coupling, **not** value-type sharing — an enum has no behaviour
|
|
and no persistence side effects. Creating a `common/` package for one enum would be
|
|
premature structure; if a third domain needs it, revisit then. The enum remains a
|
|
verbatim mirror of the import normalizer's `Precision` values (ADR-025) — changes must
|
|
stay in sync with `tools/import-normalizer/dates.py`.
|
|
|
|
### 2. Precision columns are NOT NULL with default `UNKNOWN`
|
|
|
|
Mirrors `Document.metaDatePrecision`. The illegal state "date present, precision null"
|
|
cannot exist: the named CHECK constraints
|
|
(`chk_person_birth_date_precision_coherence`, `…_values`,
|
|
`chk_person_birth_before_death`, and the death-side twins) enforce
|
|
`(date IS NULL) = (precision = 'UNKNOWN')` and the temporal order at the DB level;
|
|
`PersonService.validateLifeDates` enforces the same rules first so users get a
|
|
structured 400 (`INVALID_DATE_PRECISION` / `BIRTH_AFTER_DEATH`) instead of a
|
|
constraint-violation 500.
|
|
|
|
Storage accepts all seven `DatePrecision` values (enum-to-string mapping consistency),
|
|
but the person new/edit form offers only **DAY / MONTH / YEAR** — `RANGE` and `SEASON`
|
|
are semantically nonsensical for a birth or death, and `APPROX` is excluded from the
|
|
form to reduce cognitive load for the senior author audience. Legacy `APPROX` rows
|
|
still render correctly (display delegates to `formatDocumentDate`).
|
|
|
|
The edit form seeds a stored non-offered precision (`APPROX`/`SEASON`/`RANGE`) into
|
|
the select as `YEAR`, so an untouched save coerces it to `YEAR` ("ca. 1944" becomes
|
|
"1944"). Accepted: nothing currently writes those precisions to persons (the form
|
|
offers DAY/MONTH/YEAR, the importer writes YEAR/UNKNOWN, V76 backfills YEAR), so the
|
|
case is only reachable via direct API writes — and seeding `YEAR` is strictly safer
|
|
than the alternative of silently claiming `DAY` precision.
|
|
|
|
### 3. Derived-year pattern for backward-compatible DTOs
|
|
|
|
`PersonNodeDTO` (Stammbaum) and `RelationshipDTO` keep `Integer birthYear/deathYear`,
|
|
derived null-safely in the relationship services (`birthDate != null ?
|
|
birthDate.getYear() : null` — never 0, never empty string; REQ-PERSON-DATE-01). The
|
|
native queries behind `PersonSummaryDTO` project
|
|
`CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear`.
|
|
|
|
### 4. `PersonSummaryDTO` intentionally exposes years only
|
|
|
|
The person list/search views show year precision only; full precision lives on the
|
|
person detail page. Do **not** add `LocalDate getBirthDate()` to the interface without
|
|
updating all four native queries (`findAllWithDocumentCount`,
|
|
`searchWithDocumentCount`, `findTopByDocumentCount`, `findByFilter`) — the interface
|
|
projection is satisfied purely by the SQL SELECT aliases.
|
|
|
|
### 5. `preferHumanDate` extends ADR-025's human-edit-preserve rule
|
|
|
|
The importer stays year-shaped: `PersonUpsertCommand` keeps `Integer birthYear/
|
|
deathYear` because the spreadsheet only knows a year — pushing `LocalDate` into the
|
|
importer would fabricate precision. On upsert, `PersonService.preferHumanDate` returns
|
|
a `DatePrecisionPair` record (date and precision travel as one value so they cannot go
|
|
out of sync):
|
|
|
|
- existing precision DAY/MONTH/SEASON/RANGE/APPROX → hand-entered, preserved verbatim;
|
|
- existing precision YEAR/UNKNOWN → refreshed from the canonical year as
|
|
`YYYY-01-01` + `YEAR` (or cleared to null/`UNKNOWN` when the sheet has no year).
|
|
|
|
The original integer/string `preferHuman` overloads remain for non-date fields.
|
|
|
|
### 6. Known limitation — mixed-precision comparison
|
|
|
|
`validateLifeDates` compares stored `LocalDate` values. A DAY-precision birth late in
|
|
the same year as a YEAR-precision death (stored as Jan 1st) is rejected with
|
|
`BIRTH_AFTER_DEATH`. This is intentional; the `error_birth_after_death` i18n message
|
|
carries the workaround hint (enter the following year as the death year).
|
|
|
|
## Consequences
|
|
|
|
- V76 is one-way (columns dropped). Rollback = targeted `pg_restore -t persons` from
|
|
the pre-deploy dump — see `docs/DEPLOYMENT.md` §5.
|
|
- Exact dates now render on person cards, hover cards, and the mention dropdown; the
|
|
Stammbaum and person list are visually unchanged.
|
|
- The Zeitstrahl can derive birth/death life-events at full precision.
|