feat(person): date + precision controls on person new/edit forms

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>
This commit is contained in:
Marcel
2026-06-12 18:20:18 +02:00
parent 4dcf8e2242
commit 9664a83dae
10 changed files with 258 additions and 89 deletions

View File

@@ -1,6 +1,7 @@
import { error, fail, redirect } from '@sveltejs/kit';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
import {
normalizePersonType,
validatePersonFields,
@@ -47,10 +48,17 @@ export const actions = {
const lastName = formData.get('lastName')?.toString().trim();
const alias = formData.get('alias')?.toString().trim() || undefined;
const notes = formData.get('notes')?.toString().trim() || undefined;
const birthYearStr = formData.get('birthYear')?.toString().trim();
const deathYearStr = formData.get('deathYear')?.toString().trim();
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
const deathYear = deathYearStr ? parseInt(deathYearStr, 10) : undefined;
// Empty date input → omit date AND precision: the backend normalises the
// absent pair to null/UNKNOWN, and a lone precision would fail the
// coherence check (INVALID_DATE_PRECISION).
const birthDate = formData.get('birthDate')?.toString().trim() || undefined;
const birthDatePrecision = birthDate
? (formData.get('birthDatePrecision')?.toString() as DatePrecision)
: undefined;
const deathDate = formData.get('deathDate')?.toString().trim() || undefined;
const deathDatePrecision = deathDate
? (formData.get('deathDatePrecision')?.toString() as DatePrecision)
: undefined;
// Must NOT use the conditional-spread idiom for generation: G 0 is a
// valid family-tree-root value. The key always travels in the body so
// an explicit clear (empty option) reaches the backend as null.
@@ -73,8 +81,8 @@ export const actions = {
lastName,
...(alias ? { alias } : {}),
...(notes ? { notes } : {}),
...(birthYear ? { birthYear } : {}),
...(deathYear ? { deathYear } : {}),
...(birthDate ? { birthDate, birthDatePrecision } : {}),
...(deathDate ? { deathDate, deathDatePrecision } : {}),
generation
}
});

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import PersonLifeDateField from '$lib/person/PersonLifeDateField.svelte';
import PersonTypeSelector from '$lib/person/PersonTypeSelector.svelte';
import {
PERSON_TYPES as TYPES,
@@ -88,32 +89,20 @@ const inputCls =
<label for="alias" class={labelCls}>{m.form_label_alias()}</label>
<input id="alias" name="alias" type="text" value={person.alias ?? ''} class={inputCls} />
</div>
<div>
<label for="birthYear" class={labelCls}>{m.person_label_birth_year()}</label>
<input
id="birthYear"
name="birthYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
value={person.birthYear ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="deathYear" class={labelCls}>{m.person_label_death_year()}</label>
<input
id="deathYear"
name="deathYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
value={person.deathYear ?? ''}
class={inputCls}
/>
</div>
<PersonLifeDateField
name="birthDate"
legend={m.person_label_birth_date()}
precisionLabel={m.person_label_birth_date_precision()}
initialIso={person.birthDate ?? ''}
initialPrecision={person.birthDatePrecision ?? null}
/>
<PersonLifeDateField
name="deathDate"
legend={m.person_label_death_date()}
precisionLabel={m.person_label_death_date_precision()}
initialIso={person.deathDate ?? ''}
initialPrecision={person.deathDatePrecision ?? null}
/>
<div class="md:col-span-2">
<label for="generation" class={labelCls}>{m.person_label_generation()}</label>
<select

View File

@@ -12,8 +12,10 @@ const personPersonal = {
firstName: 'Anna',
lastName: 'Schmidt',
alias: 'Anni',
birthYear: 1899 as number | null,
deathYear: 1972 as number | null,
birthDate: '1899-03-14' as string | null,
birthDatePrecision: 'DAY' as string | null,
deathDate: '1972-01-01' as string | null,
deathDatePrecision: 'YEAR' as string | null,
notes: 'Wohnte in Berlin.'
};
@@ -24,8 +26,10 @@ const personInstitution = {
firstName: null,
lastName: 'Acme GmbH',
alias: null,
birthYear: null,
deathYear: null,
birthDate: null,
birthDatePrecision: null,
deathDate: null,
deathDatePrecision: null,
notes: null
};
@@ -42,14 +46,14 @@ describe('PersonEditForm', () => {
await expect.element(page.getByLabelText(/titel/i)).toBeVisible();
});
it('hides the firstName / title / alias / year fields for INSTITUTION', async () => {
it('hides the firstName / title / alias / life-date fields for INSTITUTION', async () => {
render(PersonEditForm, { props: { person: personInstitution } });
await expect.element(page.getByLabelText(/vorname/i)).not.toBeInTheDocument();
await expect.element(page.getByLabelText(/^titel$/i)).not.toBeInTheDocument();
await expect.element(page.getByLabelText(/rufname/i)).not.toBeInTheDocument();
await expect.element(page.getByLabelText(/geburtsjahr/i)).not.toBeInTheDocument();
await expect.element(page.getByLabelText(/todesjahr/i)).not.toBeInTheDocument();
await expect.element(page.getByLabelText(/^geburtsdatum$/i)).not.toBeInTheDocument();
await expect.element(page.getByLabelText(/^sterbedatum$/i)).not.toBeInTheDocument();
});
it('uses the "Nachname" label for PERSON', async () => {
@@ -77,13 +81,63 @@ describe('PersonEditForm', () => {
expect(title.value).toBe('Frau Dr.');
});
it('renders birthYear and deathYear inputs with prior values', async () => {
it('renders an existing DAY-precision birth date as dd.mm.yyyy in the date input', async () => {
render(PersonEditForm, { props: { person: personPersonal } });
const birthYear = (await page.getByLabelText(/geburtsjahr/i).element()) as HTMLInputElement;
const deathYear = (await page.getByLabelText(/todesjahr/i).element()) as HTMLInputElement;
expect(birthYear.value).toBe('1899');
expect(deathYear.value).toBe('1972');
const birthInput = (await page.getByLabelText(/^geburtsdatum$/i).element()) as HTMLInputElement;
expect(birthInput.value).toBe('14.03.1899');
});
it('submits the ISO date via the hidden birthDate input', async () => {
const { container } = render(PersonEditForm, { props: { person: personPersonal } });
const hidden = container.querySelector('input[type="hidden"][name="birthDate"]');
expect((hidden as HTMLInputElement).value).toBe('1899-03-14');
});
it('offers only DAY / MONTH / YEAR precisions for life dates', async () => {
const { container } = render(PersonEditForm, { props: { person: personPersonal } });
const select = container.querySelector(
'select[name="birthDatePrecision"]'
) as HTMLSelectElement;
const values = Array.from(select.options).map((o) => o.value);
expect(values).toEqual(['DAY', 'MONTH', 'YEAR']);
});
it('hydrates the precision selects from the person prop', async () => {
const { container } = render(PersonEditForm, { props: { person: personPersonal } });
const birth = container.querySelector('select[name="birthDatePrecision"]') as HTMLSelectElement;
const death = container.querySelector('select[name="deathDatePrecision"]') as HTMLSelectElement;
expect(birth.value).toBe('DAY');
expect(death.value).toBe('YEAR');
});
it('seeds a non-form precision (APPROX legacy import) as YEAR instead of DAY', async () => {
const { container } = render(PersonEditForm, {
props: {
person: { ...personPersonal, birthDate: '1899-01-01', birthDatePrecision: 'APPROX' }
}
});
const birth = container.querySelector('select[name="birthDatePrecision"]') as HTMLSelectElement;
expect(birth.value).toBe('YEAR');
});
it('stacks the date input and precision select without overflow at 320px', async () => {
await page.viewport(320, 640);
const { container } = render(PersonEditForm, { props: { person: personPersonal } });
const input = container.querySelector('#birthDate') as HTMLElement;
const select = container.querySelector('select[name="birthDatePrecision"]') as HTMLElement;
expect(input.getBoundingClientRect().right).toBeLessThanOrEqual(320);
expect(select.getBoundingClientRect().right).toBeLessThanOrEqual(320);
// Stacked, not side by side: the select starts below the input.
expect(select.getBoundingClientRect().top).toBeGreaterThanOrEqual(
input.getBoundingClientRect().bottom
);
await page.viewport(1280, 720);
});
it('renders the notes textarea pre-filled with prior content', async () => {
@@ -103,15 +157,23 @@ describe('PersonEditForm', () => {
it('renders empty inputs when nullable fields are null', async () => {
render(PersonEditForm, {
props: { person: { ...personPersonal, title: null, alias: null, birthYear: null } }
props: {
person: {
...personPersonal,
title: null,
alias: null,
birthDate: null,
birthDatePrecision: null
}
}
});
const title = (await page.getByLabelText(/^titel/i).element()) as HTMLInputElement;
const alias = (await page.getByLabelText(/rufname/i).element()) as HTMLInputElement;
const birthYear = (await page.getByLabelText(/geburtsjahr/i).element()) as HTMLInputElement;
const birthInput = (await page.getByLabelText(/^geburtsdatum$/i).element()) as HTMLInputElement;
expect(title.value).toBe('');
expect(alias.value).toBe('');
expect(birthYear.value).toBe('');
expect(birthInput.value).toBe('');
});
// ─── generation dropdown (#689) ─────────────────────────────────────────────

View File

@@ -1,6 +1,7 @@
import { error, fail, redirect } from '@sveltejs/kit';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
import {
normalizePersonType,
validatePersonFields,
@@ -23,8 +24,17 @@ export const actions = {
const firstName = formData.get('firstName')?.toString().trim();
const lastName = formData.get('lastName')?.toString().trim();
const alias = formData.get('alias')?.toString().trim() || undefined;
const birthYearStr = formData.get('birthYear')?.toString().trim();
const deathYearStr = formData.get('deathYear')?.toString().trim();
// Empty date input → omit date AND precision: the backend normalises the
// absent pair to null/UNKNOWN, and a lone precision would fail the
// coherence check (INVALID_DATE_PRECISION).
const birthDate = formData.get('birthDate')?.toString().trim() || undefined;
const birthDatePrecision = birthDate
? (formData.get('birthDatePrecision')?.toString() as DatePrecision)
: undefined;
const deathDate = formData.get('deathDate')?.toString().trim() || undefined;
const deathDatePrecision = deathDate
? (formData.get('deathDatePrecision')?.toString() as DatePrecision)
: undefined;
const notes = formData.get('notes')?.toString().trim() || undefined;
// Must NOT use the conditional-spread idiom for generation: G 0 is a
// valid family-tree-root value. Always travels in the body so an
@@ -45,9 +55,6 @@ export const actions = {
});
}
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
const deathYear = deathYearStr ? parseInt(deathYearStr, 10) : undefined;
const api = createApiClient(fetch);
const result = await api.POST('/api/persons', {
body: {
@@ -56,8 +63,8 @@ export const actions = {
...(firstName ? { firstName } : {}),
lastName: lastName!,
...(alias ? { alias } : {}),
...(birthYear ? { birthYear } : {}),
...(deathYear ? { deathYear } : {}),
...(birthDate ? { birthDate, birthDatePrecision } : {}),
...(deathDate ? { deathDate, deathDatePrecision } : {}),
...(notes ? { notes } : {}),
generation
}

View File

@@ -2,6 +2,7 @@
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import BackButton from '$lib/shared/primitives/BackButton.svelte';
import PersonLifeDateField from '$lib/person/PersonLifeDateField.svelte';
import PersonTypeSelector from '$lib/person/PersonTypeSelector.svelte';
import { PERSON_TYPES as TYPES, type PersonType } from '$lib/person/person-validation';
@@ -102,30 +103,16 @@ const labelCls = 'mb-1 block text-sm font-medium text-ink-2';
class={inputCls}
/>
</div>
<div>
<label for="birthYear" class={labelCls}>{m.person_label_birth_year()}</label>
<input
id="birthYear"
name="birthYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
class={inputCls}
/>
</div>
<div>
<label for="deathYear" class={labelCls}>{m.person_label_death_year()}</label>
<input
id="deathYear"
name="deathYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
class={inputCls}
/>
</div>
<PersonLifeDateField
name="birthDate"
legend={m.person_label_birth_date()}
precisionLabel={m.person_label_birth_date_precision()}
/>
<PersonLifeDateField
name="deathDate"
legend={m.person_label_death_date()}
precisionLabel={m.person_label_death_date_precision()}
/>
{/if}
<div class="md:col-span-2">