feat(relationship): editable relationships with LocalDate+DatePrecision dates and notes (#841)
All checks were successful
CI / Unit & Component Tests (push) Successful in 5m10s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 5m14s
CI / fail2ban Regex (push) Successful in 50s
CI / Semgrep Security Scan (push) Successful in 27s
CI / Compose Bucket Idempotency (push) Successful in 1m5s

Closes #837

Makes `PersonRelationship` fully editable (type, related person, dates, notes), migrates its dates from `Integer fromYear/toYear` to `LocalDate + DatePrecision` (mirroring the #773 person pattern, ADR-039 / V76), activates the previously-dead `notes` column, and gives the Zeitstrahl's derived **Heirat** events full date precision for free.

Both Open Decisions confirmed as adopted: **no `@Version`** (last-write-wins, single-writer archive) and **`DELETE` ownership-mismatch aligned 403 → 404** (anti-enumeration, matching the new `PUT`).

## What's in it
- **V78** migrates `person_relationships.from_year/to_year` → `from_date`/`to_date` + NOT-NULL `*_date_precision` (default `UNKNOWN`); pre-check abort on corrupt years, `YYYY-01-01`/`YEAR` backfill, 5 named CHECK constraints, year columns dropped.
- **`PUT /api/persons/{id}/relationships/{relId}`** (`@RequirePermission(WRITE_ALL)`) re-runs every create invariant (self / coherence / order / reverse-PARENT_OF / duplicate) and re-flags family membership; orientation preserved per viewpoint.
- New `ErrorCode.INVALID_RELATIONSHIP_DATES` registered in all four sites (§3.6).
- `TimelineEventService` sources the derived marriage date from `SPOUSE_OF.fromDate` + precision.
- Frontend: `RelationshipDateField` (DAY/MONTH/YEAR), upsert-capable `AddRelationshipForm` (pre-fill + notes + in-flight submit lock), `RelationshipChip` Edit affordance, `updateRelationship` server action, read-view date range + notes, `formatRelationshipDateRange` helper. `api.ts` regenerated.
- Docs: ADR-044, db-orm/db-relationships diagrams, DEPLOYMENT §5 deploy note, RTM REQ-001…REQ-019.

## Requirements
All 19 EARS requirements implemented red/green and marked `Done` in `.specify/rtm.md`.

## Test plan
- **Backend** (targeted, green): `RelationshipMigrationTest` (Testcontainers pg16, 8), `RelationshipServiceTest` (22), `RelationshipControllerTest` (15), `RelationshipServiceIntegrationTest` (real DB, 10), `DerivedEventsAssemblyTest` (17), `ArchitectureTest` (14); `clean package` builds.
- **Frontend** (green): `relationshipDates.spec.ts`, `AddRelationshipForm.svelte.spec.ts`, `RelationshipChip.svelte.spec.ts`, `PersonRelationshipsCard.svelte.test.ts`, `page.server.spec.ts`, `messages.spec.ts`. `npm run check` = 798 (below the ~834 baseline); `npm run lint` clean.

## Notes for reviewers
- **Spec deviation:** the edit form was built by making `AddRelationshipForm` upsert-capable rather than a duplicate `EditRelationshipForm` (DRY); RTM rows reference `AddRelationshipForm.svelte.spec.ts`.
- `api.ts` regenerated from the live spec; only relationship-relevant hunks remain (one springdoc `PageableObject` field-reorder pruned).
- **Deploy:** V78 is one-way and not rolling-deploy-safe — stop old JAR → start new JAR (Flyway runs first); targeted `pg_restore -t person_relationships` for rollback. No maintenance window.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Marcel <marcel@familienarchiv>
Reviewed-on: #841
This commit was merged in pull request #841.
This commit is contained in:
2026-06-14 21:17:36 +02:00
parent 6dae4fe428
commit 8558567688
59 changed files with 2196 additions and 580 deletions

View File

@@ -651,6 +651,7 @@
"error_invalid_date_range": "Das Enddatum darf nicht vor dem Startdatum liegen.",
"error_birth_after_death": "Geburtsdatum muss vor dem Sterbedatum liegen. Tipp: Falls nur das Todesjahr bekannt ist und der Geburtstag spät im selben Jahr lag, bitte das Folgejahr eintragen.",
"error_invalid_date_precision": "Datum und Genauigkeit stimmen nicht überein.",
"error_invalid_relationship_dates": "Das Ende-Datum darf nicht vor dem Beginn-Datum liegen.",
"validation_last_name_required": "Nachname ist Pflichtfeld.",
"validation_first_name_required": "Vorname ist Pflichtfeld.",
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
@@ -1221,6 +1222,16 @@
"relation_form_field_from_year": "Von Jahr",
"relation_form_field_to_year": "Bis Jahr",
"relation_form_year_placeholder": "z.B. 1920",
"relation_label_from_date": "Beginn (Datum)",
"relation_label_to_date": "Ende (Datum)",
"relation_label_date_precision": "Genauigkeit",
"relation_precision_day": "Genaues Datum (Tag)",
"relation_precision_month": "Monat bekannt",
"relation_precision_year": "Nur Jahreszahl",
"relation_label_notes": "Notizen",
"relation_notes_placeholder": "Optionaler Hinweis zu dieser Beziehung",
"relation_date_placeholder_hint": "Leer lassen, wenn unbekannt",
"relation_edit": "Beziehung bearbeiten",
"person_relationships_heading": "Beziehungen",
"person_relationships_empty": "Noch keine Beziehungen bekannt.",
"timeline_aria_label": "Zeitachse Dokumentdichte",

View File

@@ -651,6 +651,7 @@
"error_invalid_date_range": "The end date must not be before the start date.",
"error_birth_after_death": "Birth date must be before death date. Tip: if only the death year is known and the birthday is late in the same year, enter the following year.",
"error_invalid_date_precision": "Date and precision do not match.",
"error_invalid_relationship_dates": "The end date must not be before the start date.",
"validation_last_name_required": "Last name is required.",
"validation_first_name_required": "First name is required.",
"error_ocr_service_unavailable": "The OCR service is not available.",
@@ -1221,6 +1222,16 @@
"relation_form_field_from_year": "From year",
"relation_form_field_to_year": "To year",
"relation_form_year_placeholder": "e.g. 1920",
"relation_label_from_date": "Start date",
"relation_label_to_date": "End date",
"relation_label_date_precision": "Precision",
"relation_precision_day": "Exact date (day)",
"relation_precision_month": "Month known",
"relation_precision_year": "Year only",
"relation_label_notes": "Notes",
"relation_notes_placeholder": "Optional note about this relationship",
"relation_date_placeholder_hint": "Leave empty if unknown",
"relation_edit": "Edit relationship",
"person_relationships_heading": "Relationships",
"person_relationships_empty": "No relationships known yet.",
"timeline_aria_label": "Document density timeline",

View File

@@ -651,6 +651,7 @@
"error_invalid_date_range": "La fecha final no puede ser anterior a la inicial.",
"error_birth_after_death": "La fecha de nacimiento debe ser anterior a la de defunción.",
"error_invalid_date_precision": "La fecha y la precisión no coinciden.",
"error_invalid_relationship_dates": "La fecha de fin no puede ser anterior a la de inicio.",
"validation_last_name_required": "El apellido es obligatorio.",
"validation_first_name_required": "El nombre es obligatorio.",
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
@@ -1221,6 +1222,16 @@
"relation_form_field_from_year": "Desde año",
"relation_form_field_to_year": "Hasta año",
"relation_form_year_placeholder": "ej. 1920",
"relation_label_from_date": "Fecha de inicio",
"relation_label_to_date": "Fecha de fin",
"relation_label_date_precision": "Precisión",
"relation_precision_day": "Fecha exacta (día)",
"relation_precision_month": "Mes conocido",
"relation_precision_year": "Solo año",
"relation_label_notes": "Notas",
"relation_notes_placeholder": "Nota opcional sobre esta relación",
"relation_date_placeholder_hint": "Dejar vacío si es desconocido",
"relation_edit": "Editar relación",
"person_relationships_heading": "Relaciones",
"person_relationships_empty": "Aún no se conocen relaciones.",
"timeline_aria_label": "Cronología de densidad de documentos",

View File

@@ -100,6 +100,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/persons/{id}/relationships/{relId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["updateRelationship"];
post?: never;
delete: operations["deleteRelationship"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/geschichten/{id}/items/reorder": {
parameters: {
query?: never;
@@ -1640,22 +1656,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/persons/{id}/relationships/{relId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["deleteRelationship"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/persons/{id}/aliases/{aliasId}": {
parameters: {
query?: never;
@@ -1853,6 +1853,50 @@ export interface components {
provisional: boolean;
readonly displayName: string;
};
RelationshipUpsertRequest: {
/** Format: uuid */
relatedPersonId: string;
/** @enum {string} */
relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER";
/** Format: date */
fromDate?: string;
/** @enum {string} */
fromDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
/** Format: date */
toDate?: string;
/** @enum {string} */
toDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
notes?: string;
};
RelationshipDTO: {
/** Format: uuid */
id: string;
/** Format: uuid */
personId: string;
/** Format: uuid */
relatedPersonId: string;
personDisplayName: string;
/** Format: int32 */
personBirthYear?: number;
/** Format: int32 */
personDeathYear?: number;
relatedPersonDisplayName: string;
/** Format: int32 */
relatedPersonBirthYear?: number;
/** Format: int32 */
relatedPersonDeathYear?: number;
/** @enum {string} */
relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER";
/** Format: date */
fromDate?: string;
/** @enum {string} */
fromDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
/** Format: date */
toDate?: string;
/** @enum {string} */
toDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
notes?: string;
};
JourneyReorderDTO: {
itemIds?: string[];
};
@@ -2008,42 +2052,6 @@ export interface components {
/** Format: uuid */
targetId: string;
};
CreateRelationshipRequest: {
/** Format: uuid */
relatedPersonId: string;
/** @enum {string} */
relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER";
/** Format: int32 */
fromYear?: number;
/** Format: int32 */
toYear?: number;
notes?: string;
};
RelationshipDTO: {
/** Format: uuid */
id: string;
/** Format: uuid */
personId: string;
/** Format: uuid */
relatedPersonId: string;
personDisplayName: string;
/** Format: int32 */
personBirthYear?: number;
/** Format: int32 */
personDeathYear?: number;
relatedPersonDisplayName: string;
/** Format: int32 */
relatedPersonBirthYear?: number;
/** Format: int32 */
relatedPersonDeathYear?: number;
/** @enum {string} */
relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER";
/** Format: int32 */
fromYear?: number;
/** Format: int32 */
toYear?: number;
notes?: string;
};
PersonNameAliasDTO: {
lastName: string;
firstName?: string;
@@ -3200,6 +3208,54 @@ export interface operations {
};
};
};
updateRelationship: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
relId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["RelationshipUpsertRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["RelationshipDTO"];
};
};
};
};
deleteRelationship: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
relId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
reorderItems: {
parameters: {
query?: never;
@@ -3663,7 +3719,7 @@ export interface operations {
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateRelationshipRequest"];
"application/json": components["schemas"]["RelationshipUpsertRequest"];
};
};
responses: {
@@ -5909,27 +5965,6 @@ export interface operations {
};
};
};
deleteRelationship: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
relId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
removeAlias: {
parameters: {
query?: never;

View File

@@ -193,7 +193,9 @@ describe('PersonHoverCard — loaded state', () => {
relatedPersonId: 'p-spouse',
personDisplayName: 'Auguste',
relatedPersonDisplayName: 'Otto Raddatz',
relationType: 'SPOUSE_OF'
relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
},
{
id: 'r2',
@@ -201,7 +203,9 @@ describe('PersonHoverCard — loaded state', () => {
relatedPersonId: 'p-friend',
personDisplayName: 'Auguste',
relatedPersonDisplayName: 'Karl Friend',
relationType: 'FRIEND'
relationType: 'FRIEND',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
},
{
id: 'r3',
@@ -209,7 +213,9 @@ describe('PersonHoverCard — loaded state', () => {
relatedPersonId: 'p-sibling',
personDisplayName: 'Auguste',
relatedPersonDisplayName: 'Marie Sister',
relationType: 'SIBLING_OF'
relationType: 'SIBLING_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}
];
render(PersonHoverCard, {
@@ -235,7 +241,9 @@ describe('PersonHoverCard — loaded state', () => {
relatedPersonId: 'p-aug',
personDisplayName: 'Heinrich Raddatz',
relatedPersonDisplayName: 'Auguste Raddatz',
relationType: 'PARENT_OF'
relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}
];
render(PersonHoverCard, {
@@ -258,7 +266,9 @@ describe('PersonHoverCard — loaded state', () => {
relatedPersonId: 'p-friend',
personDisplayName: 'Auguste',
relatedPersonDisplayName: 'Karl Friend',
relationType: 'FRIEND'
relationType: 'FRIEND',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}
];
render(PersonHoverCard, {

View File

@@ -1,17 +1,10 @@
<script lang="ts">
import { onMount } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import DateInput from '$lib/shared/primitives/DateInput.svelte';
import DateInputWithPrecision from '$lib/shared/primitives/DateInputWithPrecision.svelte';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
// Only DAY / MONTH / YEAR are offered for life dates: RANGE and SEASON make no
// sense for a birth or death, and APPROX stays display-only for legacy imports (#773).
const PERSON_DATE_PRECISIONS: { value: DatePrecision; label: () => string }[] = [
{ value: 'DAY', label: m.person_precision_day },
{ value: 'MONTH', label: m.person_precision_month },
{ value: 'YEAR', label: m.person_precision_year }
];
let {
name,
legend,
@@ -26,73 +19,21 @@ let {
initialPrecision?: string | null;
} = $props();
let iso = $state('');
let errorMessage = $state<string | null>(null);
let inputEl = $state<HTMLInputElement | undefined>();
let precision = $state<DatePrecision>('DAY');
// Seed once at mount (WhoWhenSection pattern): a later load() rerun must not
// stomp the user's in-progress edit.
onMount(() => {
if (initialIso) {
iso = initialIso;
}
const offered = PERSON_DATE_PRECISIONS.some((p) => p.value === initialPrecision);
if (offered) {
precision = initialPrecision as DatePrecision;
} else if (initialIso) {
// Legacy APPROX/SEASON/RANGE precision is not editable here — seed YEAR so an
// untouched save does not silently claim DAY precision for the stored date.
precision = 'YEAR';
}
});
// A partial date leaves the hidden ISO empty — submitting then would silently
// clear a stored date. Block native submission until completed or fully emptied.
$effect(() => {
inputEl?.setCustomValidity(errorMessage ?? '');
});
const controlCls =
'block min-h-[44px] w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring';
const precisions: { value: DatePrecision; label: string }[] = $derived([
{ value: 'DAY', label: m.person_precision_day() },
{ value: 'MONTH', label: m.person_precision_month() },
{ value: 'YEAR', label: m.person_precision_year() }
]);
const hint = $derived(`${m.person_precision_hint()} · ${m.person_date_placeholder_hint()}`);
</script>
<fieldset>
<legend class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
{legend}
</legend>
<div class="flex flex-col gap-2 sm:flex-row">
<div class="flex-1">
<DateInput
bind:value={iso}
bind:errorMessage={errorMessage}
bind:inputEl={inputEl}
name={name}
id={name}
placeholder="TT.MM.JJJJ"
ariaLabel={legend}
ariaDescribedby={errorMessage ? `${name}-error` : undefined}
class={controlCls}
/>
{#if errorMessage}
<p id="{name}-error" class="mt-1 font-sans text-xs text-red-600">{errorMessage}</p>
{/if}
</div>
<div class="flex-1">
<select
id="{name}Precision"
name="{name}Precision"
aria-label="{legend}: {precisionLabel}"
bind:value={precision}
class="{controlCls} bg-surface"
>
{#each PERSON_DATE_PRECISIONS as p (p.value)}
<option value={p.value}>{p.label()}</option>
{/each}
</select>
</div>
</div>
<p class="mt-1 font-sans text-xs text-ink-3">
{m.person_precision_hint()} · {m.person_date_placeholder_hint()}
</p>
</fieldset>
<DateInputWithPrecision
name={name}
legend={legend}
precisionLabel={precisionLabel}
precisions={precisions}
hint={hint}
initialIso={initialIso}
initialPrecision={initialPrecision}
selectClass="bg-surface"
/>

View File

@@ -4,6 +4,7 @@ import { m } from '$lib/paraglide/messages.js';
import RelationshipChip from '$lib/person/relationship/RelationshipChip.svelte';
import AddRelationshipForm from '$lib/person/relationship/AddRelationshipForm.svelte';
import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/person/relationshipLabels';
import { formatRelationshipDateRange } from '$lib/person/relationshipDates';
import type { components } from '$lib/generated/api';
type RelationshipDTO = components['schemas']['RelationshipDTO'];
@@ -29,13 +30,15 @@ let {
type RelationType = NonNullable<RelationshipDTO['relationType']>;
const sortedDirect = $derived([...relationships].sort(byTypeThenYear));
const sortedDirect = $derived([...relationships].sort(byTypeThenDate));
const topDerived = $derived(inferredRelationships.slice(0, 5));
let editingRelId = $state<string | null>(null);
function byTypeThenYear(a: RelationshipDTO, b: RelationshipDTO): number {
function byTypeThenDate(a: RelationshipDTO, b: RelationshipDTO): number {
const order = relationTypeOrder(a.relationType) - relationTypeOrder(b.relationType);
if (order !== 0) return order;
return (a.fromYear ?? 0) - (b.fromYear ?? 0);
// ISO dates sort lexicographically == chronologically; a missing date sorts first.
return (a.fromDate ?? '').localeCompare(b.fromDate ?? '');
}
function relationTypeOrder(t: RelationType | undefined): number {
@@ -53,13 +56,13 @@ function relationTypeOrder(t: RelationType | undefined): number {
return order[t ?? 'OTHER'] ?? 99;
}
function yearRange(rel: RelationshipDTO): string {
const from = rel.fromYear;
const to = rel.toYear;
if (from && to) return `${from}${to}`;
if (from) return m.relation_year_from({ year: from });
if (to) return m.relation_year_to({ year: to });
return '';
function dateRangeOf(rel: RelationshipDTO): string {
return formatRelationshipDateRange(
rel.fromDate,
rel.fromDatePrecision,
rel.toDate,
rel.toDatePrecision
);
}
</script>
@@ -132,10 +135,20 @@ function yearRange(rel: RelationshipDTO): string {
<RelationshipChip
chipLabel={chipLabel(rel, personId)}
otherName={otherName(rel, personId)}
yearRange={yearRange(rel)}
dateRange={dateRangeOf(rel)}
canWrite={canWrite}
relId={rel.id}
onEdit={canWrite ? () => (editingRelId = rel.id) : undefined}
/>
{#if editingRelId === rel.id}
<li>
<AddRelationshipForm
personId={personId}
relationship={rel}
onClose={() => (editingRelId = null)}
/>
</li>
{/if}
{/each}
</ul>
{/if}

View File

@@ -111,17 +111,21 @@ describe('StammbaumCard', () => {
expect(items.length).toBeGreaterThanOrEqual(2);
});
it('renders the year range "fromto" for a relationship with both years', async () => {
it('renders the date range "from to" for a relationship with both dates', async () => {
render(StammbaumCard, {
props: baseProps({
relationships: [
{
id: 'r-1',
personId: 'p-1',
relatedPersonId: 'p-x',
personDisplayName: 'Anna',
relatedPersonDisplayName: 'Xavier',
relationType: 'COLLEAGUE',
fromYear: 1940,
toYear: 1945,
personA: { id: 'p-1', displayName: 'Anna' },
personB: { id: 'p-x', displayName: 'Xavier' }
fromDate: '1940-01-01',
fromDatePrecision: 'YEAR',
toDate: '1945-01-01',
toDatePrecision: 'YEAR'
}
]
})
@@ -131,23 +135,27 @@ describe('StammbaumCard', () => {
expect(document.body.textContent).toContain('1945');
});
it('renders only "fromYear" for a relationship with no end year', async () => {
it('renders only the start date for a relationship with no end date', async () => {
render(StammbaumCard, {
props: baseProps({
relationships: [
{
id: 'r-2',
personId: 'p-1',
relatedPersonId: 'p-y',
personDisplayName: 'Anna',
relatedPersonDisplayName: 'Yvonne',
relationType: 'NEIGHBOR',
fromYear: 1935,
personA: { id: 'p-1', displayName: 'Anna' },
personB: { id: 'p-y', displayName: 'Yvonne' }
fromDate: '1935-01-01',
fromDatePrecision: 'YEAR',
toDatePrecision: 'UNKNOWN'
}
]
})
});
expect(document.body.textContent).toContain('1935');
expect(document.body.textContent).not.toContain('1935');
expect(document.body.textContent).not.toContain('1935 ');
});
it('renders the inferred-relationships disclosure when topDerived has items', async () => {

View File

@@ -250,7 +250,7 @@ const parentLinks = $derived.by<ParentLinks>(() => {
y2={bCenter.y}
stroke="var(--c-primary)"
stroke-width="1.5"
stroke-dasharray={e.toYear ? '4 4' : undefined}
stroke-dasharray={e.toDate ? '4 4' : undefined}
/>
<circle
cx={(aCenter.x + bCenter.x) / 2}

View File

@@ -18,7 +18,9 @@ function parentEdge(parentId: string, childId: string): RelationshipDTO {
relatedPersonId: childId,
personDisplayName: '',
relatedPersonDisplayName: '',
relationType: 'PARENT_OF'
relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
};
}
@@ -30,7 +32,9 @@ function endedSpouseEdge(a: string, b: string): RelationshipDTO {
personDisplayName: '',
relatedPersonDisplayName: '',
relationType: 'SPOUSE_OF',
toYear: 1950
fromDatePrecision: 'UNKNOWN',
toDate: '1950-01-01',
toDatePrecision: 'YEAR'
};
}

View File

@@ -54,12 +54,19 @@ async function loadFor(id: string) {
}
async function handleAddRelationship(data: RelFormData) {
const body: Record<string, string | number> = {
const body: Record<string, string> = {
relatedPersonId: data.relatedPersonId,
relationType: data.relationType
};
if (data.fromYear !== undefined) body.fromYear = data.fromYear;
if (data.toYear !== undefined) body.toYear = data.toYear;
if (data.fromDate) {
body.fromDate = data.fromDate;
if (data.fromDatePrecision) body.fromDatePrecision = data.fromDatePrecision;
}
if (data.toDate) {
body.toDate = data.toDate;
if (data.toDatePrecision) body.toDatePrecision = data.toDatePrecision;
}
if (data.notes) body.notes = data.notes;
const res = await csrfFetch(`/api/persons/${node.id}/relationships`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },

View File

@@ -72,20 +72,20 @@ describe('StammbaumSidePanel', () => {
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();
});
it('year inputs inside the add form have label elements (canWrite=true)', async () => {
it('date inputs inside the add form have accessible labels (canWrite=true)', async () => {
render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: true });
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();
const addBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find((b) =>
/Beziehung hinzufügen/i.test(b.textContent ?? '')
);
addBtn!.click();
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
const yearInputs = [...document.querySelectorAll('input')].filter(
await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
const dateInputs = [...document.querySelectorAll('input')].filter(
(i) => i.inputMode === 'numeric'
);
expect(yearInputs.length).toBeGreaterThan(0);
for (const input of yearInputs) {
expect(input.closest('label')).not.toBeNull();
expect(dateInputs.length).toBeGreaterThan(0);
for (const input of dateInputs) {
expect(input.getAttribute('aria-label')).toBeTruthy();
}
});

View File

@@ -3,6 +3,9 @@ import { render } from 'vitest-browser-svelte';
import StammbaumTree from './StammbaumTree.svelte';
import type { PanZoomState } from './panZoom';
import { DIMMED_OPACITY } from './layout/highlightLineage';
import type { components } from '$lib/generated/api';
type RelationshipDTO = components['schemas']['RelationshipDTO'];
const ID_A = '00000000-0000-0000-0000-000000000001';
const ID_B = '00000000-0000-0000-0000-000000000002';
@@ -105,7 +108,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: PARENT_B,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Eugenie',
relationType: 'SPOUSE_OF'
relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
},
{
id: 'p1a',
@@ -113,7 +118,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CHILD_1,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF'
relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
},
{
id: 'p1b',
@@ -121,7 +128,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CHILD_1,
personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF'
relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
},
{
id: 'p2a',
@@ -129,7 +138,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CHILD_2,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF'
relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
},
{
id: 'p2b',
@@ -137,7 +148,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CHILD_2,
personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF'
relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}
],
selectedId: null,
@@ -181,7 +194,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: PARENT_B,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Eugenie',
relationType: 'SPOUSE_OF'
relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
},
{
id: 'p1',
@@ -189,7 +204,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CHILD,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF'
relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
},
{
id: 'p2',
@@ -197,7 +214,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CHILD,
personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF'
relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}
],
selectedId: null,
@@ -244,7 +263,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: EUGENIE,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Eugenie',
relationType: 'SPOUSE_OF'
relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
},
{
id: 'p1',
@@ -252,7 +273,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: HANS,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF'
relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
},
{
id: 'p2',
@@ -260,7 +283,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: HANS,
personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF'
relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
},
{
id: 'p3',
@@ -268,7 +293,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CLARA,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF'
relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
},
{
id: 'p4',
@@ -276,7 +303,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CLARA,
personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF'
relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
},
{
id: 's2',
@@ -284,7 +313,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: HILDE,
personDisplayName: 'Hans',
relatedPersonDisplayName: 'Hilde',
relationType: 'SPOUSE_OF'
relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
},
{
id: 'p5',
@@ -292,7 +323,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: LILI,
personDisplayName: 'Hans',
relatedPersonDisplayName: 'Lili',
relationType: 'PARENT_OF'
relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
},
{
id: 'p6',
@@ -300,7 +333,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: LILI,
personDisplayName: 'Hilde',
relatedPersonDisplayName: 'Lili',
relationType: 'PARENT_OF'
relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}
],
selectedId: null,
@@ -358,7 +393,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: ID_B,
personDisplayName: 'Anna',
relatedPersonDisplayName: 'Bertha',
relationType: 'SPOUSE_OF'
relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}
],
selectedId: null,
@@ -391,7 +428,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: ID_B,
personDisplayName: 'Anna',
relatedPersonDisplayName: 'Bertha',
relationType: 'SPOUSE_OF'
relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}
],
selectedId: null,
@@ -599,7 +638,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
relatedPersonId: ID_B,
personDisplayName: 'Anna',
relatedPersonDisplayName: 'Bertha',
relationType: 'SPOUSE_OF'
relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}
],
selectedId: null,
@@ -668,7 +709,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
personDisplayName: 'Anna',
relatedPersonDisplayName: 'Bertha',
relationType: 'SPOUSE_OF',
toYear: 1925
fromDatePrecision: 'UNKNOWN',
toDate: '1925-01-01',
toDatePrecision: 'YEAR'
}
],
selectedId: null,
@@ -695,7 +738,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
relatedPersonId: ID_B,
personDisplayName: 'Anna',
relatedPersonDisplayName: 'Bertha',
relationType: 'SPOUSE_OF'
relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}
],
selectedId: null,
@@ -723,7 +768,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
relatedPersonId: CHILD,
personDisplayName: 'Parent',
relatedPersonDisplayName: 'Child',
relationType: 'PARENT_OF'
relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}
],
selectedId: null,
@@ -908,6 +955,8 @@ describe('StammbaumTree lineage highlight (#703)', () => {
personDisplayName: string;
relatedPersonDisplayName: string;
relationType: 'PARENT_OF' | 'SPOUSE_OF';
fromDatePrecision: 'UNKNOWN';
toDatePrecision: 'UNKNOWN';
};
const edge = (
personId: string,
@@ -919,7 +968,9 @@ describe('StammbaumTree lineage highlight (#703)', () => {
relatedPersonId,
personDisplayName: '',
relatedPersonDisplayName: '',
relationType
relationType,
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
});
const NODES = [
@@ -1104,14 +1155,16 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
// year, then a deterministic id tie-break), not alphabetically — with no birth
// years here Walter (id …a1) owns the run and Eugenie sits to his right. So the
// deterministic visual order is Walter, Eugenie (top row) then Clara, Hans.
const FAMILY_EDGES = [
const FAMILY_EDGES: RelationshipDTO[] = [
{
id: 'sp',
personId: WALTER,
relatedPersonId: EUGENIE,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Eugenie',
relationType: 'SPOUSE_OF'
relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
},
{
id: 'p1',
@@ -1119,7 +1172,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
relatedPersonId: CLARA,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF'
relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
},
{
id: 'p2',
@@ -1127,7 +1182,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
relatedPersonId: CLARA,
personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF'
relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
},
{
id: 'p3',
@@ -1135,7 +1192,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
relatedPersonId: HANS,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF'
relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
},
{
id: 'p4',
@@ -1143,7 +1202,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
relatedPersonId: HANS,
personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF'
relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}
];

View File

@@ -42,7 +42,9 @@ function parentEdge(parentId: string, childId: string, id = parentId + childId):
relatedPersonId: childId,
personDisplayName: '',
relatedPersonDisplayName: '',
relationType: 'PARENT_OF'
relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
};
}
@@ -53,7 +55,9 @@ function spouseEdge(a: string, b: string, id = a + b): RelationshipDTO {
relatedPersonId: b,
personDisplayName: '',
relatedPersonDisplayName: '',
relationType: 'SPOUSE_OF'
relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
};
}
@@ -220,7 +224,10 @@ describe('buildLayout — multi-spouse ordering (#361)', () => {
fromYear: number | undefined,
id = a + b
): RelationshipDTO {
return { ...spouseEdge(a, b, id), fromYear };
return {
...spouseEdge(a, b, id),
...(fromYear != null ? { fromDate: `${fromYear}-01-01`, fromDatePrecision: 'YEAR' } : {})
};
}
it('multi_spouses_ordered_by_fromYear_then_displayName', () => {
@@ -329,7 +336,7 @@ describe('buildLayout — multi-spouse ordering (#361)', () => {
// fail fast instead so the maintainer either updates the test or
// splits into a year-branch / name-branch pair.
const spouseEdgesWithYear = fixtureEdges.filter(
(e) => e.relationType === 'SPOUSE_OF' && e.fromYear != null
(e) => e.relationType === 'SPOUSE_OF' && e.fromDate != null
);
expect(
spouseEdgesWithYear,

View File

@@ -21,7 +21,9 @@ function parent(p: string, c: string): RelationshipDTO {
relatedPersonId: c,
personDisplayName: '',
relatedPersonDisplayName: '',
relationType: 'PARENT_OF'
relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
};
}
@@ -33,7 +35,9 @@ function spouse(a: string, b: string, fromYear?: number): RelationshipDTO {
personDisplayName: '',
relatedPersonDisplayName: '',
relationType: 'SPOUSE_OF',
...(fromYear != null ? { fromYear } : {})
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN',
...(fromYear != null ? { fromDate: `${fromYear}-01-01`, fromDatePrecision: 'YEAR' } : {})
};
}

View File

@@ -82,7 +82,10 @@ export function buildFamilyForest(nodes: PersonNodeDTO[], edges: RelationshipDTO
} else if (e.relationType === 'SPOUSE_OF') {
addToSet(spouses, e.personId, e.relatedPersonId);
addToSet(spouses, e.relatedPersonId, e.personId);
spouseYear.set(pairKey(e.personId, e.relatedPersonId), e.fromYear ?? undefined);
spouseYear.set(
pairKey(e.personId, e.relatedPersonId),
e.fromDate ? Number(e.fromDate.slice(0, 4)) : undefined
);
}
}

View File

@@ -13,7 +13,9 @@ function parentEdge(parentId: string, childId: string, id = parentId + childId):
relatedPersonId: childId,
personDisplayName: '',
relatedPersonDisplayName: '',
relationType: 'PARENT_OF'
relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
};
}
@@ -24,7 +26,9 @@ function spouseEdge(a: string, b: string, id = a + b): RelationshipDTO {
relatedPersonId: b,
personDisplayName: '',
relatedPersonDisplayName: '',
relationType: 'SPOUSE_OF'
relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
};
}
@@ -35,7 +39,9 @@ function siblingEdge(a: string, b: string, id = a + b): RelationshipDTO {
relatedPersonId: b,
personDisplayName: '',
relatedPersonDisplayName: '',
relationType: 'SIBLING_OF'
relationType: 'SIBLING_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
};
}

View File

@@ -1,23 +1,17 @@
import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate';
import { formatDatePart, type DatePrecision } from '$lib/shared/utils/documentDate';
/**
* Formats one life date (birth or death) at the precision the data claims,
* delegating all rendering to {@link formatDocumentDate}. Returns '' for a
* missing date. Carries no * / † glyph — components that need the glyphs wrap
* them in their own `aria-hidden` markup so screen readers only hear the date.
*
* A missing precision falls back to YEAR: pre-V76 rows only knew a year, and
* a bare year is the only safe rendering for a date without precision metadata.
* Formats one life date (birth or death) at the precision the data claims.
* Thin domain alias over the shared {@link formatDatePart}: carries no * / †
* glyph — components that need the glyphs wrap them in their own `aria-hidden`
* markup so screen readers only hear the date.
*/
export function formatLifeDate(
date: string | null | undefined,
precision: DatePrecision | null | undefined,
locale?: string
): string {
if (!date) {
return '';
}
return formatDocumentDate(date, precision ?? 'YEAR', null, null, locale);
return formatDatePart(date, precision, locale);
}
/**

View File

@@ -1,8 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
import RelationshipDateField from '$lib/person/relationship/RelationshipDateField.svelte';
import type { components } from '$lib/generated/api';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
type RelationshipDTO = components['schemas']['RelationshipDTO'];
type RelationType = NonNullable<RelationshipDTO['relationType']>;
@@ -10,71 +13,96 @@ type RelationType = NonNullable<RelationshipDTO['relationType']>;
export type RelFormData = {
relatedPersonId: string;
relationType: RelationType;
fromYear?: number;
toYear?: number;
fromDate?: string;
fromDatePrecision?: DatePrecision;
toDate?: string;
toDatePrecision?: DatePrecision;
notes?: string;
};
interface Props {
personId: string;
// When present the form is an EDIT: pre-filled and posting to ?/updateRelationship.
relationship?: RelationshipDTO;
onSubmit?: (data: RelFormData) => Promise<void>;
onClose?: () => void;
}
let { personId, onSubmit }: Props = $props();
let { personId, relationship, onSubmit, onClose }: Props = $props();
const isEdit = $derived(relationship != null);
let open = $state(false);
let addType = $state<RelationType>('PARENT_OF');
let addRelatedPersonId = $state('');
let addRelatedPersonName = $state('');
let addFromYear = $state('');
let addToYear = $state('');
let notes = $state('');
let callbackError = $state<string | null>(null);
let submitting = $state(false);
const yearError = $derived.by(() => {
const from = addFromYear.trim();
const to = addToYear.trim();
if (!from || !to) return null;
const fromInt = parseInt(from, 10);
const toInt = parseInt(to, 10);
if (Number.isNaN(fromInt) || Number.isNaN(toInt)) return null;
return toInt < fromInt ? m.relation_year_error_bis_before_von() : null;
// Seed once at mount (reading props in a closure avoids state_referenced_locally).
// The parent re-creates this form per edited row, so the relationship never
// changes under a live instance.
onMount(() => {
if (!relationship) return;
open = true;
addType = relationship.relationType ?? 'PARENT_OF';
const viewpointIsSubject = relationship.personId === personId;
addRelatedPersonId =
(viewpointIsSubject ? relationship.relatedPersonId : relationship.personId) ?? '';
addRelatedPersonName =
(viewpointIsSubject ? relationship.relatedPersonDisplayName : relationship.personDisplayName) ??
'';
notes = relationship.notes ?? '';
});
const selfError = $derived(
addRelatedPersonId !== '' && addRelatedPersonId === personId ? m.relation_error_self() : null
);
const submitDisabled = $derived(
yearError !== null || selfError !== null || addRelatedPersonId === ''
);
const submitDisabled = $derived(selfError !== null || addRelatedPersonId === '');
function reset() {
addType = 'PARENT_OF';
addRelatedPersonId = '';
addRelatedPersonName = '';
addFromYear = '';
addToYear = '';
notes = '';
callbackError = null;
}
function cancel() {
if (isEdit) {
onClose?.();
return;
}
open = false;
reset();
}
async function handleCallbackSubmit(event: Event) {
async function handleCallbackSubmit(event: SubmitEvent) {
event.preventDefault();
if (submitDisabled || !onSubmit) return;
const data: RelFormData = { relatedPersonId: addRelatedPersonId, relationType: addType };
const from = parseInt(addFromYear.trim(), 10);
if (!Number.isNaN(from)) data.fromYear = from;
const to = parseInt(addToYear.trim(), 10);
if (!Number.isNaN(to)) data.toYear = to;
const fd = new FormData(event.currentTarget as HTMLFormElement);
const fromDate = (fd.get('fromDate') as string) || undefined;
const toDate = (fd.get('toDate') as string) || undefined;
const data: RelFormData = {
relatedPersonId: addRelatedPersonId,
relationType: addType,
fromDate,
fromDatePrecision: fromDate ? (fd.get('fromDatePrecision') as DatePrecision) : undefined,
toDate,
toDatePrecision: toDate ? (fd.get('toDatePrecision') as DatePrecision) : undefined,
notes: (fd.get('notes') as string)?.trim() || undefined
};
submitting = true;
try {
await onSubmit(data);
open = false;
reset();
} catch {
callbackError = m.error_internal_error();
} finally {
submitting = false;
}
}
</script>
@@ -113,39 +141,32 @@ async function handleCallbackSubmit(event: Event) {
compact
/>
</div>
<label class="block">
<span class="font-sans text-xs font-medium text-ink-2"
>{m.relation_form_field_from_year()}</span
>
<input
type="text"
name="fromYear"
inputmode="numeric"
pattern="[0-9]*"
bind:value={addFromYear}
placeholder={m.relation_form_year_placeholder()}
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink focus:border-primary focus:outline-none"
/>
</label>
<label class="block">
<span class="font-sans text-xs font-medium text-ink-2">{m.relation_form_field_to_year()}</span
>
<input
type="text"
name="toYear"
inputmode="numeric"
pattern="[0-9]*"
bind:value={addToYear}
aria-describedby={yearError ? 'add-rel-year-error' : undefined}
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink focus:border-primary focus:outline-none"
/>
{#if yearError}
<p id="add-rel-year-error" class="mt-1 text-xs text-red-700" role="alert">
{yearError}
</p>
{/if}
</label>
</div>
<div class="mt-3 grid gap-3 md:grid-cols-2">
<RelationshipDateField
name="fromDate"
legend={m.relation_label_from_date()}
initialIso={relationship?.fromDate ?? ''}
initialPrecision={relationship?.fromDatePrecision ?? null}
/>
<RelationshipDateField
name="toDate"
legend={m.relation_label_to_date()}
initialIso={relationship?.toDate ?? ''}
initialPrecision={relationship?.toDatePrecision ?? null}
/>
</div>
<label class="mt-3 block">
<span class="font-sans text-xs font-medium text-ink-2">{m.relation_label_notes()}</span>
<textarea
name="notes"
maxlength="2000"
rows="2"
bind:value={notes}
placeholder={m.relation_notes_placeholder()}
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 font-serif text-sm text-ink-3 focus:border-primary focus:outline-none"
></textarea>
</label>
{#if selfError}
<p class="mt-2 text-xs text-red-700" role="alert">{selfError}</p>
{/if}
@@ -162,10 +183,18 @@ async function handleCallbackSubmit(event: Event) {
</button>
<button
type="submit"
disabled={submitDisabled}
class="rounded-sm bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg transition hover:bg-primary/80 disabled:opacity-40"
disabled={submitDisabled || submitting}
aria-busy={submitting}
class="inline-flex items-center gap-1.5 rounded-sm bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg transition hover:bg-primary/80 disabled:opacity-40"
>
{m.relation_btn_add()}
{#if submitting}
<span
class="h-3 w-3 animate-spin rounded-full border-2 border-primary-fg/40 border-t-primary-fg"
data-testid="submit-spinner"
aria-hidden="true"
></span>
{/if}
{isEdit ? m.relation_btn_save() : m.relation_btn_add()}
</button>
</div>
{/snippet}
@@ -185,18 +214,27 @@ async function handleCallbackSubmit(event: Event) {
{:else}
<form
method="POST"
action="?/addRelationship"
action={isEdit ? '?/updateRelationship' : '?/addRelationship'}
use:enhance={() => {
submitting = true;
return async ({ result, update }) => {
await update();
submitting = false;
if (result.type === 'success') {
open = false;
reset();
if (isEdit) {
onClose?.();
} else {
open = false;
reset();
}
}
};
}}
class="mt-3 rounded-sm border border-line bg-muted/40 p-3"
>
{#if relationship}
<input type="hidden" name="relId" value={relationship.id} />
{/if}
{@render formFields()}
</form>
{/if}

View File

@@ -8,58 +8,116 @@ vi.mock('$lib/person/PersonTypeahead.svelte', () => ({ default: () => null }));
afterEach(cleanup);
describe('AddRelationshipForm', () => {
it('shows add-relationship button initially and no form', async () => {
render(AddRelationshipForm, { personId: 'person-1' });
const PID = 'person-1';
const OTHER = 'person-2';
const editRel = () => ({
id: 'rel-9',
personId: PID,
relatedPersonId: OTHER,
personDisplayName: 'Anna Müller',
relatedPersonDisplayName: 'Hans Müller',
relationType: 'SPOUSE_OF' as const,
fromDate: '1923-05-12',
fromDatePrecision: 'DAY' as const,
toDatePrecision: 'UNKNOWN' as const,
notes: 'Hochzeit in Berlin'
});
describe('AddRelationshipForm — create mode', () => {
it('shows the add-relationship toggle initially and no form', async () => {
render(AddRelationshipForm, { personId: PID });
await expect.element(page.getByRole('button')).toBeInTheDocument();
await expect.element(page.getByRole('combobox')).not.toBeInTheDocument();
expect(document.querySelector('select[name="relationType"]')).toBeNull();
});
it('shows relationType select when add button is clicked', async () => {
render(AddRelationshipForm, { personId: 'person-1' });
it('shows the relationType select when the add toggle is clicked', async () => {
render(AddRelationshipForm, { personId: PID });
document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
});
it('hides form and shows button when cancel is clicked', async () => {
render(AddRelationshipForm, { personId: 'person-1' });
it('hides the form and shows the toggle again on cancel', async () => {
render(AddRelationshipForm, { personId: PID });
document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
const cancelBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
(b) => b.type === 'button' && /abbrechen/i.test(b.textContent ?? '')
);
cancelBtn!.click();
await expect.element(page.getByRole('combobox')).not.toBeInTheDocument();
await vi.waitFor(() =>
expect(document.querySelector('select[name="relationType"]')).toBeNull()
);
});
it('submit is disabled when no person is selected', async () => {
render(AddRelationshipForm, { personId: 'person-1' });
it('disables submit when no person is selected', async () => {
render(AddRelationshipForm, { personId: PID });
document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('button', { name: /^Hinzufügen$/i })).toBeDisabled();
});
it('form has no server action when onSubmit prop is provided', async () => {
it('has no server action when an onSubmit prop is provided', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(AddRelationshipForm, { personId: 'person-1', onSubmit });
render(AddRelationshipForm, { personId: PID, onSubmit });
document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
const form = document.querySelector('form');
expect(form?.hasAttribute('action')).toBe(false);
});
it('shows year-range error when toYear is before fromYear', async () => {
render(AddRelationshipForm, { personId: 'person-1' });
document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
const fromInput = document.querySelector<HTMLInputElement>('input[name="fromYear"]')!;
fromInput.value = '1935';
fromInput.dispatchEvent(new InputEvent('input', { bubbles: true }));
const toInput = document.querySelector<HTMLInputElement>('input[name="toYear"]')!;
toInput.value = '1920';
toInput.dispatchEvent(new InputEvent('input', { bubbles: true }));
await expect.element(page.getByRole('alert')).toBeVisible();
await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
expect(document.querySelector('form')?.hasAttribute('action')).toBe(false);
});
});
describe('AddRelationshipForm — edit mode', () => {
it('opens pre-filled and labels the submit "Speichern"', async () => {
render(AddRelationshipForm, { personId: PID, relationship: editRel() });
await expect.element(page.getByRole('button', { name: /^Speichern$/i })).toBeInTheDocument();
});
it('pre-fills the from-date as dd.mm.yyyy', async () => {
render(AddRelationshipForm, { personId: PID, relationship: editRel() });
await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
const fromInput = document.querySelector<HTMLInputElement>('#fromDate')!;
await vi.waitFor(() => expect(fromInput.value).toBe('12.05.1923'));
});
it('round-trips the notes into the textarea', async () => {
render(AddRelationshipForm, { personId: PID, relationship: editRel() });
await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
const notes = document.querySelector<HTMLTextAreaElement>('textarea[name="notes"]')!;
await vi.waitFor(() => expect(notes.value).toBe('Hochzeit in Berlin'));
});
it('offers only DAY/MONTH/YEAR in each precision select', async () => {
render(AddRelationshipForm, { personId: PID, relationship: editRel() });
await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
const options = [
...document.querySelectorAll<HTMLOptionElement>('#fromDatePrecision option')
].map((o) => o.value);
expect(options).toEqual(['DAY', 'MONTH', 'YEAR']);
});
it('gives each date input an associated label (accessible name)', async () => {
render(AddRelationshipForm, { personId: PID, relationship: editRel() });
await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
expect(document.querySelector('#fromDate')?.getAttribute('aria-label')).toBe('Beginn (Datum)');
expect(document.querySelector('#toDate')?.getAttribute('aria-label')).toBe('Ende (Datum)');
});
it('disables the submit and shows a progress spinner while a submit is in flight', async () => {
let resolve: () => void = () => {};
const onSubmit = vi.fn(() => new Promise<void>((r) => (resolve = r)));
render(AddRelationshipForm, { personId: PID, relationship: editRel(), onSubmit });
const submit = await vi.waitFor(() => {
const b = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
(x) => x.type === 'submit'
);
if (!b) throw new Error('submit not ready');
return b;
});
submit.click();
await expect.element(page.getByTestId('submit-spinner')).toBeInTheDocument();
await vi.waitFor(() => expect(submit.disabled).toBe(true));
expect(onSubmit).toHaveBeenCalledOnce();
resolve();
});
});

View File

@@ -33,36 +33,6 @@ describe('AddRelationshipForm', () => {
expect(optionValues).toContain('OTHER');
});
it('shows the year-error alert when toYear is before fromYear', async () => {
render(AddRelationshipForm, { props: { personId: 'p-1' } });
await page.getByRole('button', { name: /hinzufügen/i }).click();
const fromInput = document.querySelector('input[name="fromYear"]') as HTMLInputElement;
const toInput = document.querySelector('input[name="toYear"]') as HTMLInputElement;
fromInput.value = '1923';
fromInput.dispatchEvent(new Event('input', { bubbles: true }));
toInput.value = '1920';
toInput.dispatchEvent(new Event('input', { bubbles: true }));
await expect.element(page.getByText(/bis-jahr darf nicht vor von-jahr/i)).toBeVisible();
});
it('does not show the year-error when toYear equals fromYear', async () => {
render(AddRelationshipForm, { props: { personId: 'p-1' } });
await page.getByRole('button', { name: /hinzufügen/i }).click();
const fromInput = document.querySelector('input[name="fromYear"]') as HTMLInputElement;
const toInput = document.querySelector('input[name="toYear"]') as HTMLInputElement;
fromInput.value = '1923';
fromInput.dispatchEvent(new Event('input', { bubbles: true }));
toInput.value = '1923';
toInput.dispatchEvent(new Event('input', { bubbles: true }));
await expect.element(page.getByText(/bis-jahr darf nicht/i)).not.toBeInTheDocument();
});
it('cancel button closes the form', async () => {
render(AddRelationshipForm, { props: { personId: 'p-1' } });
@@ -136,25 +106,4 @@ describe('AddRelationshipForm', () => {
expect(submitBtn!.disabled).toBe(true);
});
});
it('keeps submit disabled when there is a yearError', async () => {
render(AddRelationshipForm, { props: { personId: 'p-1' } });
await page.getByRole('button', { name: /hinzufügen/i }).click();
const fromInput = document.querySelector('input[name="fromYear"]') as HTMLInputElement;
const toInput = document.querySelector('input[name="toYear"]') as HTMLInputElement;
const relInput = document.querySelector('input[name="relatedPersonId"]') as HTMLInputElement;
fromInput.value = '1923';
fromInput.dispatchEvent(new Event('input', { bubbles: true }));
toInput.value = '1920';
toInput.dispatchEvent(new Event('input', { bubbles: true }));
relInput.value = 'p-other';
relInput.dispatchEvent(new Event('input', { bubbles: true }));
await vi.waitFor(() => {
const submitBtn = document.querySelector('button[type="submit"]') as HTMLButtonElement;
expect(submitBtn.disabled).toBe(true);
});
});
});

View File

@@ -5,12 +5,13 @@ import { m } from '$lib/paraglide/messages.js';
interface Props {
chipLabel: string;
otherName: string;
yearRange?: string;
dateRange?: string;
canWrite: boolean;
relId: string;
onEdit?: () => void;
}
let { chipLabel, otherName, yearRange = '', canWrite, relId }: Props = $props();
let { chipLabel, otherName, dateRange = '', canWrite, relId, onEdit }: Props = $props();
</script>
<li class="flex items-center gap-2 py-2">
@@ -22,8 +23,31 @@ let { chipLabel, otherName, yearRange = '', canWrite, relId }: Props = $props();
<span class="min-w-0 flex-1 truncate font-serif text-sm text-ink">
{otherName}
</span>
{#if yearRange}
<span class="shrink-0 font-sans text-xs text-ink-3" data-testid="year-range">{yearRange}</span>
{#if dateRange}
<span class="shrink-0 font-sans text-xs text-ink-3" data-testid="date-range">{dateRange}</span>
{/if}
{#if canWrite && onEdit}
<button
type="button"
onclick={onEdit}
aria-label="{m.relation_edit()} {otherName}"
class="inline-flex h-11 w-11 items-center justify-center text-ink-3 transition-colors hover:text-primary"
>
<svg
class="h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931z"
/>
</svg>
</button>
{/if}
{#if canWrite}
<form method="POST" action="?/deleteRelationship" use:enhance class="shrink-0">

View File

@@ -10,7 +10,7 @@ afterEach(cleanup);
const baseProps = {
chipLabel: 'Elternteil',
otherName: 'Anna Schmidt',
yearRange: '',
dateRange: '',
canWrite: false,
relId: 'rel-1'
};
@@ -26,30 +26,55 @@ describe('RelationshipChip', () => {
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
});
it('shows year range when provided', async () => {
render(RelationshipChip, { ...baseProps, yearRange: '19201980' });
await expect.element(page.getByText('19201980')).toBeInTheDocument();
it('shows the date range when provided', async () => {
render(RelationshipChip, { ...baseProps, dateRange: '12. Mai 1923 1958' });
await expect.element(page.getByText('12. Mai 1923 1958')).toBeInTheDocument();
});
it('does not show year range span when empty', async () => {
render(RelationshipChip, { ...baseProps, yearRange: '' });
expect(document.querySelector('[data-testid="year-range"]')).toBeNull();
it('does not render a date-range span when empty', async () => {
render(RelationshipChip, { ...baseProps, dateRange: '' });
expect(document.querySelector('[data-testid="date-range"]')).toBeNull();
});
it('shows delete button when canWrite is true', async () => {
it('shows the delete button when canWrite is true', async () => {
render(RelationshipChip, { ...baseProps, canWrite: true });
await expect.element(page.getByRole('button')).toBeInTheDocument();
});
it('hides delete button when canWrite is false', async () => {
it('hides the delete button when canWrite is false', async () => {
render(RelationshipChip, { ...baseProps, canWrite: false });
expect(document.querySelector('button')).toBeNull();
});
it('delete button has h-11 w-11 (44px) WCAG touch target class', async () => {
it('gives the delete button an h-11 w-11 (44px) WCAG touch target', async () => {
render(RelationshipChip, { ...baseProps, canWrite: true });
const btn = document.querySelector('button')!;
expect(btn.className).toContain('h-11');
expect(btn.className).toContain('w-11');
});
it('shows an Edit affordance with an accessible name when canWrite and onEdit', async () => {
render(RelationshipChip, { ...baseProps, canWrite: true, onEdit: () => {} });
await expect
.element(page.getByRole('button', { name: /Beziehung bearbeiten/i }))
.toBeInTheDocument();
});
it('does not show the Edit affordance without onEdit', async () => {
render(RelationshipChip, { ...baseProps, canWrite: true });
expect(document.querySelector('button[aria-label*="bearbeiten"]')).toBeNull();
});
it('does not show the Edit affordance when canWrite is false', async () => {
render(RelationshipChip, { ...baseProps, canWrite: false, onEdit: () => {} });
expect(document.querySelector('button[aria-label*="bearbeiten"]')).toBeNull();
});
it('calls onEdit when the Edit affordance is clicked', async () => {
const onEdit = vi.fn();
render(RelationshipChip, { ...baseProps, canWrite: true, onEdit });
const editBtn = document.querySelector<HTMLButtonElement>('button[aria-label*="bearbeiten"]')!;
editBtn.click();
expect(onEdit).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import DateInputWithPrecision from '$lib/shared/primitives/DateInputWithPrecision.svelte';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
// Only DAY / MONTH / YEAR are offered (same as the person life-date field and the
// 60+ author audience). Storage still accepts all seven precisions; SEASON/RANGE/
// APPROX render correctly elsewhere but make no sense to enter for a relationship.
let {
name,
legend,
initialIso = '',
initialPrecision = null
}: {
name: string;
legend: string;
initialIso?: string | null;
initialPrecision?: string | null;
} = $props();
const precisions: { value: DatePrecision; label: string }[] = $derived([
{ value: 'DAY', label: m.relation_precision_day() },
{ value: 'MONTH', label: m.relation_precision_month() },
{ value: 'YEAR', label: m.relation_precision_year() }
]);
</script>
<DateInputWithPrecision
name={name}
legend={legend}
precisionLabel={m.relation_label_date_precision()}
precisions={precisions}
hint={m.relation_date_placeholder_hint()}
initialIso={initialIso}
initialPrecision={initialPrecision}
inputClass="bg-surface"
selectClass="bg-surface text-ink-3"
/>

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import { formatRelationshipDateRange } from './relationshipDates';
// Delegates all precision rendering to formatDocumentDate — these tests pin the
// composition (dash, single sides, empty state) and one rendering per precision,
// plus en/es for DAY/MONTH so a German-month leak is caught here, not on a card.
describe('formatRelationshipDateRange', () => {
describe('both dates (de default)', () => {
it('renders DAY precision as full dates', () => {
expect(formatRelationshipDateRange('1923-05-12', 'DAY', '1958-06-13', 'DAY')).toBe(
'12. Mai 1923 13. Juni 1958'
);
});
it('renders MONTH precision as month + year', () => {
expect(formatRelationshipDateRange('1923-05-01', 'MONTH', '1958-06-01', 'MONTH')).toBe(
'Mai 1923 Juni 1958'
);
});
it('renders YEAR precision as bare years', () => {
expect(formatRelationshipDateRange('1923-01-01', 'YEAR', '1958-01-01', 'YEAR')).toBe(
'1923 1958'
);
});
it('renders mixed precisions per side', () => {
expect(formatRelationshipDateRange('1923-05-12', 'DAY', '1958-01-01', 'YEAR')).toBe(
'12. Mai 1923 1958'
);
});
});
describe('single sides and empty states', () => {
it('renders from only without a trailing dash', () => {
expect(formatRelationshipDateRange('1923-05-12', 'DAY', null, null)).toBe('12. Mai 1923');
});
it('renders to only with a leading dash', () => {
expect(formatRelationshipDateRange(null, null, '1958-06-13', 'DAY')).toBe(' 13. Juni 1958');
});
it('renders nothing when both dates are missing (UNKNOWN)', () => {
expect(formatRelationshipDateRange(null, 'UNKNOWN', null, 'UNKNOWN')).toBe('');
});
it('renders nothing for a from-only with a null date', () => {
expect(formatRelationshipDateRange(null, null, null, null)).toBe('');
});
});
describe('localized months (catch German-month leak)', () => {
it('renders DAY in English with no German month name', () => {
const out = formatRelationshipDateRange('1923-05-12', 'DAY', null, null, 'en');
expect(out).toContain('May');
expect(out).not.toContain('Mai');
expect(out).toContain('1923');
});
it('renders MONTH in Spanish', () => {
const out = formatRelationshipDateRange('1923-05-01', 'MONTH', null, null, 'es');
expect(out.toLowerCase()).toContain('mayo');
});
});
});

View File

@@ -0,0 +1,30 @@
import { formatDatePart, type DatePrecision } from '$lib/shared/utils/documentDate';
/**
* Formats a relationship's startend range as plain text, e.g. for a marriage row.
* Examples (de):
* 12. Mai 1923 13. Juni 1958 (both)
* 12. Mai 1923 (start only — no trailing dash)
* 13. Juni 1958 (end only)
* "" (neither — the caller renders no date line)
*/
export function formatRelationshipDateRange(
fromDate: string | null | undefined,
fromDatePrecision: DatePrecision | null | undefined,
toDate: string | null | undefined,
toDatePrecision: DatePrecision | null | undefined,
locale?: string
): string {
const from = formatDatePart(fromDate, fromDatePrecision, locale);
const to = formatDatePart(toDate, toDatePrecision, locale);
if (from && to) {
return `${from} ${to}`;
}
if (from) {
return from;
}
if (to) {
return ` ${to}`;
}
return '';
}

View File

@@ -19,6 +19,8 @@ function makeRel(
personDisplayName: 'Alice',
relatedPersonDisplayName: 'Bob',
relationType,
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN',
...override
};
}

View File

@@ -10,6 +10,7 @@ export type ErrorCode =
| 'INVALID_PERSON_TYPE'
| 'BIRTH_AFTER_DEATH'
| 'INVALID_DATE_PRECISION'
| 'INVALID_RELATIONSHIP_DATES'
| 'INVALID_DATE_RANGE'
| 'DOCUMENT_NOT_FOUND'
| 'DOCUMENT_NO_FILE'
@@ -106,6 +107,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_birth_after_death();
case 'INVALID_DATE_PRECISION':
return m.error_invalid_date_precision();
case 'INVALID_RELATIONSHIP_DATES':
return m.error_invalid_relationship_dates();
case 'INVALID_DATE_RANGE':
return m.error_invalid_date_range();
case 'DOCUMENT_NOT_FOUND':

View File

@@ -0,0 +1,106 @@
<script lang="ts">
import { onMount } from 'svelte';
import DateInput from '$lib/shared/primitives/DateInput.svelte';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
/**
* Compact date + precision field: the {@link DateInput} primitive paired with a
* precision <select> offering a caller-chosen subset of precisions. Shared base of
* PersonLifeDateField (birth/death) and RelationshipDateField (from/to).
*
* Distinct from {@link DatePrecisionField} — that one is the full document/timeline
* field (all seven precisions, German free-text entry, RANGE end-date disclosure).
* This one is the restricted, single-input variant for the person-family forms.
*
* All copy (legend, precision labels, hint, the select's accessible name) and the
* offered precisions are injected by the caller so this stays domain-agnostic.
*/
let {
name,
legend,
precisionLabel,
hint,
precisions,
initialIso = '',
initialPrecision = null,
inputClass = '',
selectClass = ''
}: {
name: string;
legend: string;
precisionLabel: string;
hint: string;
precisions: { value: DatePrecision; label: string }[];
initialIso?: string | null;
initialPrecision?: string | null;
inputClass?: string;
selectClass?: string;
} = $props();
let iso = $state('');
let errorMessage = $state<string | null>(null);
let inputEl = $state<HTMLInputElement | undefined>();
let precision = $state<DatePrecision>('DAY');
// Seed once at mount so a later load() rerun does not stomp an in-progress edit.
onMount(() => {
if (initialIso) {
iso = initialIso;
}
const offered = precisions.some((p) => p.value === initialPrecision);
if (offered) {
precision = initialPrecision as DatePrecision;
} else if (initialIso) {
// A stored non-offered precision (SEASON/RANGE/APPROX) seeds as YEAR so an
// untouched save does not silently claim DAY precision for the stored date.
precision = 'YEAR';
}
});
// A partial date leaves the hidden ISO empty — block native submission until the
// date is completed or fully emptied, so a save can never silently clear a date.
$effect(() => {
inputEl?.setCustomValidity(errorMessage ?? '');
});
const controlCls =
'block min-h-[44px] w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring';
</script>
<fieldset>
<legend class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
{legend}
</legend>
<div class="flex flex-col gap-2 sm:flex-row">
<div class="flex-1">
<DateInput
bind:value={iso}
bind:errorMessage={errorMessage}
bind:inputEl={inputEl}
name={name}
id={name}
placeholder="TT.MM.JJJJ"
ariaLabel={legend}
ariaDescribedby={errorMessage ? `${name}-error` : undefined}
class="{controlCls} {inputClass}"
/>
{#if errorMessage}
<p id="{name}-error" class="mt-1 font-sans text-xs text-red-600">{errorMessage}</p>
{/if}
</div>
<div class="flex-1">
<select
id="{name}Precision"
name="{name}Precision"
aria-label="{legend}: {precisionLabel}"
bind:value={precision}
class="{controlCls} {selectClass}"
>
{#each precisions as p (p.value)}
<option value={p.value}>{p.label}</option>
{/each}
</select>
</div>
</div>
<p class="mt-1 font-sans text-xs text-ink-3">{hint}</p>
</fieldset>

View File

@@ -66,6 +66,27 @@ export function formatDocumentDate(
}
}
/**
* Formats one nullable date at the precision the data claims, delegating all
* rendering to {@link formatDocumentDate}. Returns '' for a missing date; a
* missing precision falls back to YEAR — pre-precision rows knew only a year,
* and a bare year is the only safe rendering without precision metadata.
*
* This is the shared core of {@link formatLifeDate} (person birth/death) and the
* relationship from/to formatter. Range-level glyphs and dashes belong in those
* domain wrappers, never here.
*/
export function formatDatePart(
date: string | null | undefined,
precision: DatePrecision | null | undefined,
locale?: string
): string {
if (!date) {
return '';
}
return formatDocumentDate(date, precision ?? 'YEAR', null, null, locale);
}
// ─── precision branches ──────────────────────────────────────────────────────
function longDate(iso: string, locale: string): string {

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/person/relationshipLabels';
import { formatRelationshipDateRange } from '$lib/person/relationshipDates';
import type { components } from '$lib/generated/api';
type RelationshipDTO = components['schemas']['RelationshipDTO'];
@@ -35,18 +36,37 @@ function otherId(rel: RelationshipDTO): string {
{#if relationships.length > 0}
<ul class="mb-4 space-y-2">
{#each relationships as rel (rel.id)}
<li class="flex items-center gap-2">
<span
class="inline-flex shrink-0 items-center rounded-full border border-accent/40 bg-accent/15 px-2 py-0.5 font-sans text-xs font-bold tracking-widest text-ink uppercase"
>
{chipLabel(rel, personId)}
</span>
<a
href="/persons/{otherId(rel)}"
class="min-w-0 flex-1 truncate font-serif text-sm text-ink hover:underline"
>
{otherName(rel, personId)}
</a>
{@const dateRange = formatRelationshipDateRange(
rel.fromDate,
rel.fromDatePrecision,
rel.toDate,
rel.toDatePrecision
)}
<li class="flex flex-col gap-0.5">
<div class="flex items-center gap-2">
<span
class="inline-flex shrink-0 items-center rounded-full border border-accent/40 bg-accent/15 px-2 py-0.5 font-sans text-xs font-bold tracking-widest text-ink uppercase"
>
{chipLabel(rel, personId)}
</span>
<a
href="/persons/{otherId(rel)}"
class="min-w-0 flex-1 truncate font-serif text-sm text-ink hover:underline"
>
{otherName(rel, personId)}
</a>
{#if dateRange}
<span
class="shrink-0 font-sans text-xs text-ink-3"
data-testid="relationship-date-range">{dateRange}</span
>
{/if}
</div>
{#if rel.notes}
<p class="pl-1 font-serif text-xs text-ink-2 italic" data-testid="relationship-notes">
{rel.notes}
</p>
{/if}
</li>
{/each}
</ul>

View File

@@ -18,7 +18,9 @@ describe('PersonRelationshipsCard', () => {
relatedPersonId: SPOUSE_ID,
personDisplayName: 'Anna Müller',
relatedPersonDisplayName: 'Bertha Müller',
relationType: 'SPOUSE_OF'
relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}
],
inferredRelationships: [
@@ -65,7 +67,9 @@ describe('PersonRelationshipsCard', () => {
relatedPersonId: PARENT_ID,
personDisplayName: 'Anna Müller',
relatedPersonDisplayName: 'Kind Müller',
relationType: 'PARENT_OF'
relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}
],
inferredRelationships: []
@@ -84,7 +88,9 @@ describe('PersonRelationshipsCard', () => {
relatedPersonId: SPOUSE_ID,
personDisplayName: 'Anna',
relatedPersonDisplayName: 'Bertha',
relationType: 'SPOUSE_OF'
relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}
],
inferredRelationships: [
@@ -113,7 +119,9 @@ describe('PersonRelationshipsCard', () => {
relatedPersonId: PERSON_ID,
personDisplayName: 'Eltern Müller',
relatedPersonDisplayName: 'Anna Müller',
relationType: 'PARENT_OF'
relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}
],
inferredRelationships: []
@@ -121,4 +129,74 @@ describe('PersonRelationshipsCard', () => {
await expect.element(page.getByText('Kind von')).toBeInTheDocument();
});
it('renders the date range at its stored precision', async () => {
render(PersonRelationshipsCard, {
personId: PERSON_ID,
relationships: [
{
id: 'r1',
personId: PERSON_ID,
relatedPersonId: SPOUSE_ID,
personDisplayName: 'Anna Müller',
relatedPersonDisplayName: 'Bertha Müller',
relationType: 'SPOUSE_OF',
fromDate: '1923-05-12',
fromDatePrecision: 'DAY',
toDatePrecision: 'UNKNOWN'
}
],
inferredRelationships: []
});
await expect
.element(page.getByTestId('relationship-date-range'))
.toHaveTextContent('12. Mai 1923');
});
it('shows the notes line', async () => {
render(PersonRelationshipsCard, {
personId: PERSON_ID,
relationships: [
{
id: 'r1',
personId: PERSON_ID,
relatedPersonId: SPOUSE_ID,
personDisplayName: 'Anna Müller',
relatedPersonDisplayName: 'Bertha Müller',
relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN',
notes: 'Hochzeit in Berlin'
}
],
inferredRelationships: []
});
await expect
.element(page.getByTestId('relationship-notes'))
.toHaveTextContent('Hochzeit in Berlin');
});
it('renders no date line when the relationship has no dates', async () => {
render(PersonRelationshipsCard, {
personId: PERSON_ID,
relationships: [
{
id: 'r1',
personId: PERSON_ID,
relatedPersonId: SPOUSE_ID,
personDisplayName: 'Anna Müller',
relatedPersonDisplayName: 'Bertha Müller',
relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}
],
inferredRelationships: []
});
await expect.element(page.getByText('Bertha Müller')).toBeInTheDocument();
expect(document.querySelector('[data-testid="relationship-date-range"]')).toBeNull();
});
});

View File

@@ -2,12 +2,40 @@ 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 type { components } from '$lib/generated/api';
import {
normalizePersonType,
validatePersonFields,
resolveValidationMessage
} from '$lib/person/person-validation';
type RelationType = NonNullable<components['schemas']['RelationshipUpsertRequest']['relationType']>;
// Parses the shared relationship create/update form into a RelationshipUpsertRequest
// body. An empty date omits date AND precision so the backend normalises the pair to
// null/UNKNOWN — a lone precision would fail the coherence check (INVALID_DATE_PRECISION).
function parseRelationshipForm(formData: FormData) {
const relatedPersonId = formData.get('relatedPersonId')?.toString();
const relationType = formData.get('relationType')?.toString();
const notes = formData.get('notes')?.toString().trim() || undefined;
const fromDate = formData.get('fromDate')?.toString().trim() || undefined;
const fromDatePrecision = fromDate
? (formData.get('fromDatePrecision')?.toString() as DatePrecision)
: undefined;
const toDate = formData.get('toDate')?.toString().trim() || undefined;
const toDatePrecision = toDate
? (formData.get('toDatePrecision')?.toString() as DatePrecision)
: undefined;
const body = {
relatedPersonId: relatedPersonId ?? '',
relationType: (relationType ?? 'OTHER') as RelationType,
...(fromDate ? { fromDate, fromDatePrecision } : {}),
...(toDate ? { toDate, toDatePrecision } : {}),
...(notes ? { notes } : {})
};
return { relatedPersonId, relationType, body };
}
export async function load({ params, fetch, locals }) {
const canWrite =
(locals.user as { groups?: { permissions: string[] }[] } | undefined)?.groups?.some((g) =>
@@ -193,40 +221,45 @@ export const actions = {
addRelationship: async ({ request, params, fetch }) => {
const formData = await request.formData();
const relatedPersonId = formData.get('relatedPersonId')?.toString();
const relationType = formData.get('relationType')?.toString();
const fromYearRaw = formData.get('fromYear')?.toString().trim();
const toYearRaw = formData.get('toYear')?.toString().trim();
const notes = formData.get('notes')?.toString().trim() || undefined;
const fields = parseRelationshipForm(formData);
if (!relatedPersonId || !relationType) {
if (!fields.relatedPersonId || !fields.relationType) {
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
}
if (relatedPersonId === params.id) {
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
}
const fromYear = fromYearRaw ? parseInt(fromYearRaw, 10) : undefined;
const toYear = toYearRaw ? parseInt(toYearRaw, 10) : undefined;
if (
fromYear !== undefined &&
toYear !== undefined &&
!Number.isNaN(fromYear) &&
!Number.isNaN(toYear) &&
toYear < fromYear
) {
if (fields.relatedPersonId === params.id) {
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
}
const api = createApiClient(fetch);
const result = await api.POST('/api/persons/{id}/relationships', {
params: { path: { id: params.id } },
body: {
relatedPersonId,
relationType,
...(fromYear !== undefined && !Number.isNaN(fromYear) ? { fromYear } : {}),
...(toYear !== undefined && !Number.isNaN(toYear) ? { toYear } : {}),
...(notes ? { notes } : {})
}
body: fields.body
});
if (!result.response.ok) {
return fail(result.response.status, {
relationshipError: getErrorMessage(extractErrorCode(result.error))
});
}
return { relationshipSuccess: true };
},
updateRelationship: async ({ request, params, fetch }) => {
const formData = await request.formData();
const relId = formData.get('relId')?.toString();
const fields = parseRelationshipForm(formData);
if (!relId || !fields.relatedPersonId || !fields.relationType) {
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
}
if (fields.relatedPersonId === params.id) {
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
}
const api = createApiClient(fetch);
const result = await api.PUT('/api/persons/{id}/relationships/{relId}', {
params: { path: { id: params.id, relId } },
body: fields.body
});
if (!result.response.ok) {

View File

@@ -97,3 +97,98 @@ describe('persons/[id]/edit update action — generation (#689)', () => {
expect(body).toHaveProperty('generation', 3);
});
});
describe('persons/[id]/edit relationship actions (#837)', () => {
function relForm(overrides: Record<string, string | null> = {}): Request {
const fd = new FormData();
fd.set('relatedPersonId', 'p2');
fd.set('relationType', 'SPOUSE_OF');
for (const [k, v] of Object.entries(overrides)) {
if (v == null) fd.delete(k);
else fd.set(k, v);
}
return new Request('http://localhost/persons/p1/edit', { method: 'POST', body: fd });
}
it('addRelationship posts date + precision + notes', async () => {
const post = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: {} });
vi.mocked(createApiClient).mockReturnValue({ POST: post } as unknown as ReturnType<
typeof createApiClient
>);
const request = relForm({
fromDate: '1923-05-12',
fromDatePrecision: 'DAY',
notes: 'Hochzeit'
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await actions.addRelationship({ request, params: { id: 'p1' }, fetch: mockFetch } as any);
const [path, opts] = post.mock.calls[0];
expect(path).toBe('/api/persons/{id}/relationships');
expect(opts.body).toMatchObject({
relatedPersonId: 'p2',
relationType: 'SPOUSE_OF',
fromDate: '1923-05-12',
fromDatePrecision: 'DAY',
notes: 'Hochzeit'
});
});
it('addRelationship omits precision when the date is empty (coherence)', async () => {
const post = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: {} });
vi.mocked(createApiClient).mockReturnValue({ POST: post } as unknown as ReturnType<
typeof createApiClient
>);
const request = relForm({ fromDatePrecision: 'DAY' }); // precision but no date
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await actions.addRelationship({ request, params: { id: 'p1' }, fetch: mockFetch } as any);
const body = post.mock.calls[0][1].body;
expect(body).not.toHaveProperty('fromDate');
expect(body).not.toHaveProperty('fromDatePrecision');
});
it('updateRelationship PUTs to the relId path with the new body', async () => {
const put = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: {} });
vi.mocked(createApiClient).mockReturnValue({ PUT: put } as unknown as ReturnType<
typeof createApiClient
>);
const request = relForm({ relId: 'rel-9', fromDate: '1923-05-12', fromDatePrecision: 'DAY' });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await actions.updateRelationship({ request, params: { id: 'p1' }, fetch: mockFetch } as any);
const [path, opts] = put.mock.calls[0];
expect(path).toBe('/api/persons/{id}/relationships/{relId}');
expect(opts.params.path).toMatchObject({ id: 'p1', relId: 'rel-9' });
expect(opts.body).toMatchObject({
relatedPersonId: 'p2',
relationType: 'SPOUSE_OF',
fromDate: '1923-05-12',
fromDatePrecision: 'DAY'
});
});
it('updateRelationship surfaces a backend error as a fail', async () => {
const put = vi.fn().mockResolvedValue({
response: { ok: false, status: 400 },
error: { code: 'INVALID_RELATIONSHIP_DATES' }
});
vi.mocked(createApiClient).mockReturnValue({ PUT: put } as unknown as ReturnType<
typeof createApiClient
>);
const request = relForm({ relId: 'rel-9' });
const result = (await actions.updateRelationship({
request,
params: { id: 'p1' },
fetch: mockFetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)) as { status: number; data: { relationshipError: string } };
expect(result.status).toBe(400);
expect(result.data.relationshipError).toBeTruthy();
});
});