feat(relationship): date+precision edit UI, notes, and read-view display

Regenerate api.ts for the LocalDate+DatePrecision RelationshipDTO /
RelationshipUpsertRequest and the new PUT, then migrate every caller:

- RelationshipDateField (mirrors PersonLifeDateField: DAY/MONTH/YEAR, 44px
  targets, labelled, semantic dark-mode tokens, relation_* i18n keys).
- AddRelationshipForm is now upsert-capable: an optional `relationship` prop
  pre-fills type, person, both dates+precision and notes; posts to
  ?/updateRelationship (else ?/addRelationship); the submit control disables and
  shows a progress spinner while a request is in flight (REQ-019); notes textarea
  (<=2000).
- RelationshipChip gains an accessible Edit affordance (canWrite + onEdit);
  StammbaumCard wires it, formats the date range via formatRelationshipDateRange,
  and sorts by fromDate. PersonRelationshipsCard (read view) shows the date range
  and notes; no dates -> no date line.
- persons/[id]/edit/+page.server.ts: updateRelationship action (PUT) + the
  addRelationship action reshaped to date+precision+notes (empty date omits
  precision for coherence).
- Genealogy callers fixed for the dropped year fields: familyForest spouse-order
  and StammbaumConnectors ended-edge dashing now key off fromDate/toDate.
- i18n relation_* form keys in de/en/es.

REQ-004, REQ-014, REQ-015, REQ-016, REQ-019

Refs #837
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-14 19:24:24 +02:00
parent 4d9b165a2d
commit 491d1a015a
27 changed files with 960 additions and 353 deletions

View File

@@ -1221,6 +1221,16 @@
"relation_form_field_from_year": "Von Jahr", "relation_form_field_from_year": "Von Jahr",
"relation_form_field_to_year": "Bis Jahr", "relation_form_field_to_year": "Bis Jahr",
"relation_form_year_placeholder": "z.B. 1920", "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_heading": "Beziehungen",
"person_relationships_empty": "Noch keine Beziehungen bekannt.", "person_relationships_empty": "Noch keine Beziehungen bekannt.",
"timeline_aria_label": "Zeitachse Dokumentdichte", "timeline_aria_label": "Zeitachse Dokumentdichte",

View File

@@ -1221,6 +1221,16 @@
"relation_form_field_from_year": "From year", "relation_form_field_from_year": "From year",
"relation_form_field_to_year": "To year", "relation_form_field_to_year": "To year",
"relation_form_year_placeholder": "e.g. 1920", "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_heading": "Relationships",
"person_relationships_empty": "No relationships known yet.", "person_relationships_empty": "No relationships known yet.",
"timeline_aria_label": "Document density timeline", "timeline_aria_label": "Document density timeline",

View File

@@ -1221,6 +1221,16 @@
"relation_form_field_from_year": "Desde año", "relation_form_field_from_year": "Desde año",
"relation_form_field_to_year": "Hasta año", "relation_form_field_to_year": "Hasta año",
"relation_form_year_placeholder": "ej. 1920", "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_heading": "Relaciones",
"person_relationships_empty": "Aún no se conocen relaciones.", "person_relationships_empty": "Aún no se conocen relaciones.",
"timeline_aria_label": "Cronología de densidad de documentos", "timeline_aria_label": "Cronología de densidad de documentos",

View File

@@ -100,6 +100,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/geschichten/{id}/items/reorder": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1640,22 +1656,6 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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}": { "/api/persons/{id}/aliases/{aliasId}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1853,6 +1853,50 @@ export interface components {
provisional: boolean; provisional: boolean;
readonly displayName: string; 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: { JourneyReorderDTO: {
itemIds?: string[]; itemIds?: string[];
}; };
@@ -2008,42 +2052,6 @@ export interface components {
/** Format: uuid */ /** Format: uuid */
targetId: string; 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: { PersonNameAliasDTO: {
lastName: string; lastName: string;
firstName?: string; firstName?: string;
@@ -3196,6 +3204,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: { reorderItems: {
parameters: { parameters: {
query?: never; query?: never;
@@ -3659,7 +3715,7 @@ export interface operations {
}; };
requestBody: { requestBody: {
content: { content: {
"application/json": components["schemas"]["CreateRelationshipRequest"]; "application/json": components["schemas"]["RelationshipUpsertRequest"];
}; };
}; };
responses: { responses: {
@@ -5905,27 +5961,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: { removeAlias: {
parameters: { parameters: {
query?: never; query?: never;

View File

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

View File

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

View File

@@ -111,17 +111,21 @@ describe('StammbaumCard', () => {
expect(items.length).toBeGreaterThanOrEqual(2); 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, { render(StammbaumCard, {
props: baseProps({ props: baseProps({
relationships: [ relationships: [
{ {
id: 'r-1', id: 'r-1',
personId: 'p-1',
relatedPersonId: 'p-x',
personDisplayName: 'Anna',
relatedPersonDisplayName: 'Xavier',
relationType: 'COLLEAGUE', relationType: 'COLLEAGUE',
fromYear: 1940, fromDate: '1940-01-01',
toYear: 1945, fromDatePrecision: 'YEAR',
personA: { id: 'p-1', displayName: 'Anna' }, toDate: '1945-01-01',
personB: { id: 'p-x', displayName: 'Xavier' } toDatePrecision: 'YEAR'
} }
] ]
}) })
@@ -131,23 +135,27 @@ describe('StammbaumCard', () => {
expect(document.body.textContent).toContain('1945'); 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, { render(StammbaumCard, {
props: baseProps({ props: baseProps({
relationships: [ relationships: [
{ {
id: 'r-2', id: 'r-2',
personId: 'p-1',
relatedPersonId: 'p-y',
personDisplayName: 'Anna',
relatedPersonDisplayName: 'Yvonne',
relationType: 'NEIGHBOR', relationType: 'NEIGHBOR',
fromYear: 1935, fromDate: '1935-01-01',
personA: { id: 'p-1', displayName: 'Anna' }, fromDatePrecision: 'YEAR',
personB: { id: 'p-y', displayName: 'Yvonne' } toDatePrecision: 'UNKNOWN'
} }
] ]
}) })
}); });
expect(document.body.textContent).toContain('1935'); 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 () => { 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} y2={bCenter.y}
stroke="var(--c-primary)" stroke="var(--c-primary)"
stroke-width="1.5" stroke-width="1.5"
stroke-dasharray={e.toYear ? '4 4' : undefined} stroke-dasharray={e.toDate ? '4 4' : undefined}
/> />
<circle <circle
cx={(aCenter.x + bCenter.x) / 2} cx={(aCenter.x + bCenter.x) / 2}

View File

@@ -18,7 +18,9 @@ function parentEdge(parentId: string, childId: string): RelationshipDTO {
relatedPersonId: childId, relatedPersonId: childId,
personDisplayName: '', personDisplayName: '',
relatedPersonDisplayName: '', relatedPersonDisplayName: '',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}; };
} }
@@ -30,7 +32,9 @@ function endedSpouseEdge(a: string, b: string): RelationshipDTO {
personDisplayName: '', personDisplayName: '',
relatedPersonDisplayName: '', relatedPersonDisplayName: '',
relationType: 'SPOUSE_OF', 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) { async function handleAddRelationship(data: RelFormData) {
const body: Record<string, string | number> = { const body: Record<string, string> = {
relatedPersonId: data.relatedPersonId, relatedPersonId: data.relatedPersonId,
relationType: data.relationType relationType: data.relationType
}; };
if (data.fromYear !== undefined) body.fromYear = data.fromYear; if (data.fromDate) {
if (data.toYear !== undefined) body.toYear = data.toYear; 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`, { const res = await csrfFetch(`/api/persons/${node.id}/relationships`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },

View File

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

View File

@@ -3,6 +3,9 @@ import { render } from 'vitest-browser-svelte';
import StammbaumTree from './StammbaumTree.svelte'; import StammbaumTree from './StammbaumTree.svelte';
import type { PanZoomState } from './panZoom'; import type { PanZoomState } from './panZoom';
import { DIMMED_OPACITY } from './layout/highlightLineage'; 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_A = '00000000-0000-0000-0000-000000000001';
const ID_B = '00000000-0000-0000-0000-000000000002'; const ID_B = '00000000-0000-0000-0000-000000000002';
@@ -105,7 +108,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: PARENT_B, relatedPersonId: PARENT_B,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Eugenie', relatedPersonDisplayName: 'Eugenie',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p1a', id: 'p1a',
@@ -113,7 +118,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CHILD_1, relatedPersonId: CHILD_1,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Clara', relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p1b', id: 'p1b',
@@ -121,7 +128,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CHILD_1, relatedPersonId: CHILD_1,
personDisplayName: 'Eugenie', personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Clara', relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p2a', id: 'p2a',
@@ -129,7 +138,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CHILD_2, relatedPersonId: CHILD_2,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Hans', relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p2b', id: 'p2b',
@@ -137,7 +148,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CHILD_2, relatedPersonId: CHILD_2,
personDisplayName: 'Eugenie', personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Hans', relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
selectedId: null, selectedId: null,
@@ -181,7 +194,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: PARENT_B, relatedPersonId: PARENT_B,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Eugenie', relatedPersonDisplayName: 'Eugenie',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p1', id: 'p1',
@@ -189,7 +204,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CHILD, relatedPersonId: CHILD,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Hans', relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p2', id: 'p2',
@@ -197,7 +214,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CHILD, relatedPersonId: CHILD,
personDisplayName: 'Eugenie', personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Hans', relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
selectedId: null, selectedId: null,
@@ -244,7 +263,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: EUGENIE, relatedPersonId: EUGENIE,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Eugenie', relatedPersonDisplayName: 'Eugenie',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p1', id: 'p1',
@@ -252,7 +273,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: HANS, relatedPersonId: HANS,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Hans', relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p2', id: 'p2',
@@ -260,7 +283,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: HANS, relatedPersonId: HANS,
personDisplayName: 'Eugenie', personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Hans', relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p3', id: 'p3',
@@ -268,7 +293,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CLARA, relatedPersonId: CLARA,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Clara', relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p4', id: 'p4',
@@ -276,7 +303,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CLARA, relatedPersonId: CLARA,
personDisplayName: 'Eugenie', personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Clara', relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 's2', id: 's2',
@@ -284,7 +313,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: HILDE, relatedPersonId: HILDE,
personDisplayName: 'Hans', personDisplayName: 'Hans',
relatedPersonDisplayName: 'Hilde', relatedPersonDisplayName: 'Hilde',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p5', id: 'p5',
@@ -292,7 +323,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: LILI, relatedPersonId: LILI,
personDisplayName: 'Hans', personDisplayName: 'Hans',
relatedPersonDisplayName: 'Lili', relatedPersonDisplayName: 'Lili',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p6', id: 'p6',
@@ -300,7 +333,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: LILI, relatedPersonId: LILI,
personDisplayName: 'Hilde', personDisplayName: 'Hilde',
relatedPersonDisplayName: 'Lili', relatedPersonDisplayName: 'Lili',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
selectedId: null, selectedId: null,
@@ -358,7 +393,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: ID_B, relatedPersonId: ID_B,
personDisplayName: 'Anna', personDisplayName: 'Anna',
relatedPersonDisplayName: 'Bertha', relatedPersonDisplayName: 'Bertha',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
selectedId: null, selectedId: null,
@@ -391,7 +428,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: ID_B, relatedPersonId: ID_B,
personDisplayName: 'Anna', personDisplayName: 'Anna',
relatedPersonDisplayName: 'Bertha', relatedPersonDisplayName: 'Bertha',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
selectedId: null, selectedId: null,
@@ -599,7 +638,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
relatedPersonId: ID_B, relatedPersonId: ID_B,
personDisplayName: 'Anna', personDisplayName: 'Anna',
relatedPersonDisplayName: 'Bertha', relatedPersonDisplayName: 'Bertha',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
selectedId: null, selectedId: null,
@@ -668,7 +709,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
personDisplayName: 'Anna', personDisplayName: 'Anna',
relatedPersonDisplayName: 'Bertha', relatedPersonDisplayName: 'Bertha',
relationType: 'SPOUSE_OF', relationType: 'SPOUSE_OF',
toYear: 1925 fromDatePrecision: 'UNKNOWN',
toDate: '1925-01-01',
toDatePrecision: 'YEAR'
} }
], ],
selectedId: null, selectedId: null,
@@ -695,7 +738,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
relatedPersonId: ID_B, relatedPersonId: ID_B,
personDisplayName: 'Anna', personDisplayName: 'Anna',
relatedPersonDisplayName: 'Bertha', relatedPersonDisplayName: 'Bertha',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
selectedId: null, selectedId: null,
@@ -723,7 +768,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
relatedPersonId: CHILD, relatedPersonId: CHILD,
personDisplayName: 'Parent', personDisplayName: 'Parent',
relatedPersonDisplayName: 'Child', relatedPersonDisplayName: 'Child',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
selectedId: null, selectedId: null,
@@ -908,6 +955,8 @@ describe('StammbaumTree lineage highlight (#703)', () => {
personDisplayName: string; personDisplayName: string;
relatedPersonDisplayName: string; relatedPersonDisplayName: string;
relationType: 'PARENT_OF' | 'SPOUSE_OF'; relationType: 'PARENT_OF' | 'SPOUSE_OF';
fromDatePrecision: 'UNKNOWN';
toDatePrecision: 'UNKNOWN';
}; };
const edge = ( const edge = (
personId: string, personId: string,
@@ -919,7 +968,9 @@ describe('StammbaumTree lineage highlight (#703)', () => {
relatedPersonId, relatedPersonId,
personDisplayName: '', personDisplayName: '',
relatedPersonDisplayName: '', relatedPersonDisplayName: '',
relationType relationType,
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}); });
const NODES = [ const NODES = [
@@ -1104,14 +1155,16 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
// year, then a deterministic id tie-break), not alphabetically — with no birth // 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 // 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. // deterministic visual order is Walter, Eugenie (top row) then Clara, Hans.
const FAMILY_EDGES = [ const FAMILY_EDGES: RelationshipDTO[] = [
{ {
id: 'sp', id: 'sp',
personId: WALTER, personId: WALTER,
relatedPersonId: EUGENIE, relatedPersonId: EUGENIE,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Eugenie', relatedPersonDisplayName: 'Eugenie',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p1', id: 'p1',
@@ -1119,7 +1172,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
relatedPersonId: CLARA, relatedPersonId: CLARA,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Clara', relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p2', id: 'p2',
@@ -1127,7 +1182,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
relatedPersonId: CLARA, relatedPersonId: CLARA,
personDisplayName: 'Eugenie', personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Clara', relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p3', id: 'p3',
@@ -1135,7 +1192,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
relatedPersonId: HANS, relatedPersonId: HANS,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Hans', relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p4', id: 'p4',
@@ -1143,7 +1202,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
relatedPersonId: HANS, relatedPersonId: HANS,
personDisplayName: 'Eugenie', personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Hans', 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, relatedPersonId: childId,
personDisplayName: '', personDisplayName: '',
relatedPersonDisplayName: '', 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, relatedPersonId: b,
personDisplayName: '', personDisplayName: '',
relatedPersonDisplayName: '', relatedPersonDisplayName: '',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}; };
} }
@@ -220,7 +224,10 @@ describe('buildLayout — multi-spouse ordering (#361)', () => {
fromYear: number | undefined, fromYear: number | undefined,
id = a + b id = a + b
): RelationshipDTO { ): 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', () => { 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 // fail fast instead so the maintainer either updates the test or
// splits into a year-branch / name-branch pair. // splits into a year-branch / name-branch pair.
const spouseEdgesWithYear = fixtureEdges.filter( const spouseEdgesWithYear = fixtureEdges.filter(
(e) => e.relationType === 'SPOUSE_OF' && e.fromYear != null (e) => e.relationType === 'SPOUSE_OF' && e.fromDate != null
); );
expect( expect(
spouseEdgesWithYear, spouseEdgesWithYear,

View File

@@ -21,7 +21,9 @@ function parent(p: string, c: string): RelationshipDTO {
relatedPersonId: c, relatedPersonId: c,
personDisplayName: '', personDisplayName: '',
relatedPersonDisplayName: '', 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: '', personDisplayName: '',
relatedPersonDisplayName: '', relatedPersonDisplayName: '',
relationType: 'SPOUSE_OF', 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') { } else if (e.relationType === 'SPOUSE_OF') {
addToSet(spouses, e.personId, e.relatedPersonId); addToSet(spouses, e.personId, e.relatedPersonId);
addToSet(spouses, e.relatedPersonId, e.personId); 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, relatedPersonId: childId,
personDisplayName: '', personDisplayName: '',
relatedPersonDisplayName: '', 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, relatedPersonId: b,
personDisplayName: '', personDisplayName: '',
relatedPersonDisplayName: '', 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, relatedPersonId: b,
personDisplayName: '', personDisplayName: '',
relatedPersonDisplayName: '', relatedPersonDisplayName: '',
relationType: 'SIBLING_OF' relationType: 'SIBLING_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}; };
} }

View File

@@ -1,8 +1,11 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import PersonTypeahead from '$lib/person/PersonTypeahead.svelte'; import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
import RelationshipDateField from '$lib/person/relationship/RelationshipDateField.svelte';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
type RelationshipDTO = components['schemas']['RelationshipDTO']; type RelationshipDTO = components['schemas']['RelationshipDTO'];
type RelationType = NonNullable<RelationshipDTO['relationType']>; type RelationType = NonNullable<RelationshipDTO['relationType']>;
@@ -10,71 +13,96 @@ type RelationType = NonNullable<RelationshipDTO['relationType']>;
export type RelFormData = { export type RelFormData = {
relatedPersonId: string; relatedPersonId: string;
relationType: RelationType; relationType: RelationType;
fromYear?: number; fromDate?: string;
toYear?: number; fromDatePrecision?: DatePrecision;
toDate?: string;
toDatePrecision?: DatePrecision;
notes?: string;
}; };
interface Props { interface Props {
personId: string; personId: string;
// When present the form is an EDIT: pre-filled and posting to ?/updateRelationship.
relationship?: RelationshipDTO;
onSubmit?: (data: RelFormData) => Promise<void>; 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 open = $state(false);
let addType = $state<RelationType>('PARENT_OF'); let addType = $state<RelationType>('PARENT_OF');
let addRelatedPersonId = $state(''); let addRelatedPersonId = $state('');
let addRelatedPersonName = $state(''); let addRelatedPersonName = $state('');
let addFromYear = $state(''); let notes = $state('');
let addToYear = $state('');
let callbackError = $state<string | null>(null); let callbackError = $state<string | null>(null);
let submitting = $state(false);
const yearError = $derived.by(() => { // Seed once at mount (reading props in a closure avoids state_referenced_locally).
const from = addFromYear.trim(); // The parent re-creates this form per edited row, so the relationship never
const to = addToYear.trim(); // changes under a live instance.
if (!from || !to) return null; onMount(() => {
const fromInt = parseInt(from, 10); if (!relationship) return;
const toInt = parseInt(to, 10); open = true;
if (Number.isNaN(fromInt) || Number.isNaN(toInt)) return null; addType = relationship.relationType ?? 'PARENT_OF';
return toInt < fromInt ? m.relation_year_error_bis_before_von() : null; const viewpointIsSubject = relationship.personId === personId;
addRelatedPersonId =
(viewpointIsSubject ? relationship.relatedPersonId : relationship.personId) ?? '';
addRelatedPersonName =
(viewpointIsSubject ? relationship.relatedPersonDisplayName : relationship.personDisplayName) ??
'';
notes = relationship.notes ?? '';
}); });
const selfError = $derived( const selfError = $derived(
addRelatedPersonId !== '' && addRelatedPersonId === personId ? m.relation_error_self() : null addRelatedPersonId !== '' && addRelatedPersonId === personId ? m.relation_error_self() : null
); );
const submitDisabled = $derived( const submitDisabled = $derived(selfError !== null || addRelatedPersonId === '');
yearError !== null || selfError !== null || addRelatedPersonId === ''
);
function reset() { function reset() {
addType = 'PARENT_OF'; addType = 'PARENT_OF';
addRelatedPersonId = ''; addRelatedPersonId = '';
addRelatedPersonName = ''; addRelatedPersonName = '';
addFromYear = ''; notes = '';
addToYear = '';
callbackError = null; callbackError = null;
} }
function cancel() { function cancel() {
if (isEdit) {
onClose?.();
return;
}
open = false; open = false;
reset(); reset();
} }
async function handleCallbackSubmit(event: Event) { async function handleCallbackSubmit(event: SubmitEvent) {
event.preventDefault(); event.preventDefault();
if (submitDisabled || !onSubmit) return; if (submitDisabled || !onSubmit) return;
const data: RelFormData = { relatedPersonId: addRelatedPersonId, relationType: addType }; const fd = new FormData(event.currentTarget as HTMLFormElement);
const from = parseInt(addFromYear.trim(), 10); const fromDate = (fd.get('fromDate') as string) || undefined;
if (!Number.isNaN(from)) data.fromYear = from; const toDate = (fd.get('toDate') as string) || undefined;
const to = parseInt(addToYear.trim(), 10); const data: RelFormData = {
if (!Number.isNaN(to)) data.toYear = to; 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 { try {
await onSubmit(data); await onSubmit(data);
open = false; open = false;
reset(); reset();
} catch { } catch {
callbackError = m.error_internal_error(); callbackError = m.error_internal_error();
} finally {
submitting = false;
} }
} }
</script> </script>
@@ -113,39 +141,32 @@ async function handleCallbackSubmit(event: Event) {
compact compact
/> />
</div> </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>
<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} {#if selfError}
<p class="mt-2 text-xs text-red-700" role="alert">{selfError}</p> <p class="mt-2 text-xs text-red-700" role="alert">{selfError}</p>
{/if} {/if}
@@ -162,10 +183,18 @@ async function handleCallbackSubmit(event: Event) {
</button> </button>
<button <button
type="submit" type="submit"
disabled={submitDisabled} disabled={submitDisabled || submitting}
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" 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> </button>
</div> </div>
{/snippet} {/snippet}
@@ -185,18 +214,27 @@ async function handleCallbackSubmit(event: Event) {
{:else} {:else}
<form <form
method="POST" method="POST"
action="?/addRelationship" action={isEdit ? '?/updateRelationship' : '?/addRelationship'}
use:enhance={() => { use:enhance={() => {
submitting = true;
return async ({ result, update }) => { return async ({ result, update }) => {
await update(); await update();
submitting = false;
if (result.type === 'success') { if (result.type === 'success') {
open = false; if (isEdit) {
reset(); onClose?.();
} else {
open = false;
reset();
}
} }
}; };
}} }}
class="mt-3 rounded-sm border border-line bg-muted/40 p-3" 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()} {@render formFields()}
</form> </form>
{/if} {/if}

View File

@@ -8,58 +8,116 @@ vi.mock('$lib/person/PersonTypeahead.svelte', () => ({ default: () => null }));
afterEach(cleanup); afterEach(cleanup);
describe('AddRelationshipForm', () => { const PID = 'person-1';
it('shows add-relationship button initially and no form', async () => { const OTHER = 'person-2';
render(AddRelationshipForm, { personId: 'person-1' });
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('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 () => { it('shows the relationType select when the add toggle is clicked', async () => {
render(AddRelationshipForm, { personId: 'person-1' }); render(AddRelationshipForm, { personId: PID });
document.querySelector<HTMLButtonElement>('button')!.click(); 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 () => { it('hides the form and shows the toggle again on cancel', async () => {
render(AddRelationshipForm, { personId: 'person-1' }); render(AddRelationshipForm, { personId: PID });
document.querySelector<HTMLButtonElement>('button')!.click(); 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( const cancelBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
(b) => b.type === 'button' && /abbrechen/i.test(b.textContent ?? '') (b) => b.type === 'button' && /abbrechen/i.test(b.textContent ?? '')
); );
cancelBtn!.click(); 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 () => { it('disables submit when no person is selected', async () => {
render(AddRelationshipForm, { personId: 'person-1' }); render(AddRelationshipForm, { personId: PID });
document.querySelector<HTMLButtonElement>('button')!.click(); document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('button', { name: /^Hinzufügen$/i })).toBeDisabled(); 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); const onSubmit = vi.fn().mockResolvedValue(undefined);
render(AddRelationshipForm, { personId: 'person-1', onSubmit }); render(AddRelationshipForm, { personId: PID, onSubmit });
document.querySelector<HTMLButtonElement>('button')!.click(); document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('combobox')).toBeInTheDocument(); await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
const form = document.querySelector('form'); expect(document.querySelector('form')?.hasAttribute('action')).toBe(false);
expect(form?.hasAttribute('action')).toBe(false); });
}); });
it('shows year-range error when toYear is before fromYear', async () => { describe('AddRelationshipForm — edit mode', () => {
render(AddRelationshipForm, { personId: 'person-1' }); it('opens pre-filled and labels the submit "Speichern"', async () => {
document.querySelector<HTMLButtonElement>('button')!.click(); render(AddRelationshipForm, { personId: PID, relationship: editRel() });
await expect.element(page.getByRole('combobox')).toBeInTheDocument(); await expect.element(page.getByRole('button', { name: /^Speichern$/i })).toBeInTheDocument();
});
const fromInput = document.querySelector<HTMLInputElement>('input[name="fromYear"]')!;
fromInput.value = '1935'; it('pre-fills the from-date as dd.mm.yyyy', async () => {
fromInput.dispatchEvent(new InputEvent('input', { bubbles: true })); render(AddRelationshipForm, { personId: PID, relationship: editRel() });
await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
const toInput = document.querySelector<HTMLInputElement>('input[name="toYear"]')!; const fromInput = document.querySelector<HTMLInputElement>('#fromDate')!;
toInput.value = '1920'; await vi.waitFor(() => expect(fromInput.value).toBe('12.05.1923'));
toInput.dispatchEvent(new InputEvent('input', { bubbles: true })); });
await expect.element(page.getByRole('alert')).toBeVisible(); 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'); 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 () => { it('cancel button closes the form', async () => {
render(AddRelationshipForm, { props: { personId: 'p-1' } }); render(AddRelationshipForm, { props: { personId: 'p-1' } });
@@ -136,25 +106,4 @@ describe('AddRelationshipForm', () => {
expect(submitBtn!.disabled).toBe(true); 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 { interface Props {
chipLabel: string; chipLabel: string;
otherName: string; otherName: string;
yearRange?: string; dateRange?: string;
canWrite: boolean; canWrite: boolean;
relId: string; relId: string;
onEdit?: () => void;
} }
let { chipLabel, otherName, yearRange = '', canWrite, relId }: Props = $props(); let { chipLabel, otherName, dateRange = '', canWrite, relId, onEdit }: Props = $props();
</script> </script>
<li class="flex items-center gap-2 py-2"> <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"> <span class="min-w-0 flex-1 truncate font-serif text-sm text-ink">
{otherName} {otherName}
</span> </span>
{#if yearRange} {#if dateRange}
<span class="shrink-0 font-sans text-xs text-ink-3" data-testid="year-range">{yearRange}</span> <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}
{#if canWrite} {#if canWrite}
<form method="POST" action="?/deleteRelationship" use:enhance class="shrink-0"> <form method="POST" action="?/deleteRelationship" use:enhance class="shrink-0">

View File

@@ -10,7 +10,7 @@ afterEach(cleanup);
const baseProps = { const baseProps = {
chipLabel: 'Elternteil', chipLabel: 'Elternteil',
otherName: 'Anna Schmidt', otherName: 'Anna Schmidt',
yearRange: '', dateRange: '',
canWrite: false, canWrite: false,
relId: 'rel-1' relId: 'rel-1'
}; };
@@ -26,30 +26,55 @@ describe('RelationshipChip', () => {
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument(); await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
}); });
it('shows year range when provided', async () => { it('shows the date range when provided', async () => {
render(RelationshipChip, { ...baseProps, yearRange: '19201980' }); render(RelationshipChip, { ...baseProps, dateRange: '12. Mai 1923 1958' });
await expect.element(page.getByText('19201980')).toBeInTheDocument(); await expect.element(page.getByText('12. Mai 1923 1958')).toBeInTheDocument();
}); });
it('does not show year range span when empty', async () => { it('does not render a date-range span when empty', async () => {
render(RelationshipChip, { ...baseProps, yearRange: '' }); render(RelationshipChip, { ...baseProps, dateRange: '' });
expect(document.querySelector('[data-testid="year-range"]')).toBeNull(); 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 }); render(RelationshipChip, { ...baseProps, canWrite: true });
await expect.element(page.getByRole('button')).toBeInTheDocument(); 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 }); render(RelationshipChip, { ...baseProps, canWrite: false });
expect(document.querySelector('button')).toBeNull(); 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 }); render(RelationshipChip, { ...baseProps, canWrite: true });
const btn = document.querySelector('button')!; const btn = document.querySelector('button')!;
expect(btn.className).toContain('h-11'); expect(btn.className).toContain('h-11');
expect(btn.className).toContain('w-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,97 @@
<script lang="ts">
import { onMount } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import DateInput from '$lib/shared/primitives/DateInput.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.
const RELATIONSHIP_DATE_PRECISIONS: { value: DatePrecision; label: () => string }[] = [
{ value: 'DAY', label: m.relation_precision_day },
{ value: 'MONTH', label: m.relation_precision_month },
{ value: 'YEAR', label: m.relation_precision_year }
];
let {
name,
legend,
initialIso = '',
initialPrecision = null
}: {
name: string;
legend: string;
initialIso?: string | null;
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 so a later load() rerun does not stomp an in-progress edit.
onMount(() => {
if (initialIso) {
iso = initialIso;
}
const offered = RELATIONSHIP_DATE_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} bg-surface"
/>
{#if errorMessage}
<p id="{name}-error" class="mt-1 font-sans text-xs text-red-600">{errorMessage}</p>
{/if}
</div>
<div class="flex-1">
<label class="sr-only" for="{name}Precision"
>{legend}: {m.relation_label_date_precision()}</label
>
<select
id="{name}Precision"
name="{name}Precision"
aria-label="{legend}: {m.relation_label_date_precision()}"
bind:value={precision}
class="{controlCls} bg-surface text-ink-3"
>
{#each RELATIONSHIP_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.relation_date_placeholder_hint()}</p>
</fieldset>

View File

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

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/person/relationshipLabels'; import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/person/relationshipLabels';
import { formatRelationshipDateRange } from '$lib/person/relationshipDates';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
type RelationshipDTO = components['schemas']['RelationshipDTO']; type RelationshipDTO = components['schemas']['RelationshipDTO'];
@@ -35,18 +36,37 @@ function otherId(rel: RelationshipDTO): string {
{#if relationships.length > 0} {#if relationships.length > 0}
<ul class="mb-4 space-y-2"> <ul class="mb-4 space-y-2">
{#each relationships as rel (rel.id)} {#each relationships as rel (rel.id)}
<li class="flex items-center gap-2"> {@const dateRange = formatRelationshipDateRange(
<span rel.fromDate,
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" rel.fromDatePrecision,
> rel.toDate,
{chipLabel(rel, personId)} rel.toDatePrecision
</span> )}
<a <li class="flex flex-col gap-0.5">
href="/persons/{otherId(rel)}" <div class="flex items-center gap-2">
class="min-w-0 flex-1 truncate font-serif text-sm text-ink hover:underline" <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"
{otherName(rel, personId)} >
</a> {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> </li>
{/each} {/each}
</ul> </ul>

View File

@@ -18,7 +18,9 @@ describe('PersonRelationshipsCard', () => {
relatedPersonId: SPOUSE_ID, relatedPersonId: SPOUSE_ID,
personDisplayName: 'Anna Müller', personDisplayName: 'Anna Müller',
relatedPersonDisplayName: 'Bertha Müller', relatedPersonDisplayName: 'Bertha Müller',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
inferredRelationships: [ inferredRelationships: [
@@ -65,7 +67,9 @@ describe('PersonRelationshipsCard', () => {
relatedPersonId: PARENT_ID, relatedPersonId: PARENT_ID,
personDisplayName: 'Anna Müller', personDisplayName: 'Anna Müller',
relatedPersonDisplayName: 'Kind Müller', relatedPersonDisplayName: 'Kind Müller',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
inferredRelationships: [] inferredRelationships: []
@@ -84,7 +88,9 @@ describe('PersonRelationshipsCard', () => {
relatedPersonId: SPOUSE_ID, relatedPersonId: SPOUSE_ID,
personDisplayName: 'Anna', personDisplayName: 'Anna',
relatedPersonDisplayName: 'Bertha', relatedPersonDisplayName: 'Bertha',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
inferredRelationships: [ inferredRelationships: [
@@ -113,7 +119,9 @@ describe('PersonRelationshipsCard', () => {
relatedPersonId: PERSON_ID, relatedPersonId: PERSON_ID,
personDisplayName: 'Eltern Müller', personDisplayName: 'Eltern Müller',
relatedPersonDisplayName: 'Anna Müller', relatedPersonDisplayName: 'Anna Müller',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
inferredRelationships: [] inferredRelationships: []
@@ -121,4 +129,74 @@ describe('PersonRelationshipsCard', () => {
await expect.element(page.getByText('Kind von')).toBeInTheDocument(); 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 { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors'; import { getErrorMessage } from '$lib/shared/errors';
import type { DatePrecision } from '$lib/shared/utils/documentDate'; import type { DatePrecision } from '$lib/shared/utils/documentDate';
import type { components } from '$lib/generated/api';
import { import {
normalizePersonType, normalizePersonType,
validatePersonFields, validatePersonFields,
resolveValidationMessage resolveValidationMessage
} from '$lib/person/person-validation'; } 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 }) { export async function load({ params, fetch, locals }) {
const canWrite = const canWrite =
(locals.user as { groups?: { permissions: string[] }[] } | undefined)?.groups?.some((g) => (locals.user as { groups?: { permissions: string[] }[] } | undefined)?.groups?.some((g) =>
@@ -193,40 +221,45 @@ export const actions = {
addRelationship: async ({ request, params, fetch }) => { addRelationship: async ({ request, params, fetch }) => {
const formData = await request.formData(); const formData = await request.formData();
const relatedPersonId = formData.get('relatedPersonId')?.toString(); const fields = parseRelationshipForm(formData);
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;
if (!relatedPersonId || !relationType) { if (!fields.relatedPersonId || !fields.relationType) {
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') }); return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
} }
if (relatedPersonId === params.id) { if (fields.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
) {
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') }); return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
} }
const api = createApiClient(fetch); const api = createApiClient(fetch);
const result = await api.POST('/api/persons/{id}/relationships', { const result = await api.POST('/api/persons/{id}/relationships', {
params: { path: { id: params.id } }, params: { path: { id: params.id } },
body: { body: fields.body
relatedPersonId, });
relationType,
...(fromYear !== undefined && !Number.isNaN(fromYear) ? { fromYear } : {}), if (!result.response.ok) {
...(toYear !== undefined && !Number.isNaN(toYear) ? { toYear } : {}), return fail(result.response.status, {
...(notes ? { notes } : {}) 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) { if (!result.response.ok) {

View File

@@ -97,3 +97,98 @@ describe('persons/[id]/edit update action — generation (#689)', () => {
expect(body).toHaveProperty('generation', 3); 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();
});
});