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:
@@ -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
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) ─────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user