merge(feat/person-birth-death-years): resolve conflicts with main, bump migration to V6
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 1m45s
CI / Backend Unit Tests (pull_request) Successful in 2m4s
CI / E2E Tests (pull_request) Failing after 18m40s
CI / Unit & Component Tests (push) Successful in 1m57s
CI / Backend Unit Tests (push) Successful in 2m13s
CI / E2E Tests (push) Failing after 18m39s

Resolves merge conflicts with main (feat/person-notes merged first).
Combines both features: birth/death years and notes field on person detail.
Renames migration V5__add_birth_death_years to V6 to avoid Flyway conflict.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit was merged in pull request #28.
This commit is contained in:
Marcel
2026-03-19 22:04:44 +01:00
15 changed files with 194 additions and 16 deletions

View File

@@ -59,7 +59,7 @@ public class PersonController {
? Integer.parseInt(body.get("birthYear")) : null;
Integer deathYear = body.get("deathYear") != null && !body.get("deathYear").isBlank()
? Integer.parseInt(body.get("deathYear")) : null;
return ResponseEntity.ok(personService.updatePerson(id, firstName.trim(), lastName.trim(), body.get("alias"), birthYear, deathYear));
return ResponseEntity.ok(personService.updatePerson(id, firstName.trim(), lastName.trim(), body.get("alias"), body.get("notes"), birthYear, deathYear));
}
@PostMapping("/{id}/merge")

View File

@@ -29,6 +29,10 @@ public class Person {
// Optional: Aliasse für die Suche (z.B. "Opa Hans")
private String alias;
// Optional: Free-text biographical notes
@Column(columnDefinition = "TEXT")
private String notes;
private Integer birthYear;
private Integer deathYear;
}

View File

@@ -58,7 +58,7 @@ public class PersonService {
}
@Transactional
public Person updatePerson(UUID id, String firstName, String lastName, String alias, Integer birthYear, Integer deathYear) {
public Person updatePerson(UUID id, String firstName, String lastName, String alias, String notes, Integer birthYear, Integer deathYear) {
if (birthYear != null && deathYear != null && birthYear > deathYear) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr darf nicht nach dem Todesjahr liegen");
}
@@ -67,6 +67,7 @@ public class PersonService {
person.setFirstName(firstName);
person.setLastName(lastName);
person.setAlias(alias == null || alias.isBlank() ? null : alias.trim());
person.setNotes(notes == null || notes.isBlank() ? null : notes.trim());
person.setBirthYear(birthYear);
person.setDeathYear(deathYear);
return personRepository.save(person);

View File

@@ -0,0 +1 @@
ALTER TABLE persons ADD COLUMN notes TEXT;

View File

@@ -83,6 +83,32 @@ class PersonServiceTest {
verify(personRepository).findByAliasIgnoreCase("Clara Cram");
}
// ─── updatePerson (notes) ────────────────────────────────────────────────
@Test
void updatePerson_persistsNotes() {
UUID id = UUID.randomUUID();
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
when(personRepository.findById(id)).thenReturn(Optional.of(person));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Person result = personService.updatePerson(id, "Anna", "Alt", null, "Some notes here.", null, null);
assertThat(result.getNotes()).isEqualTo("Some notes here.");
}
@Test
void updatePerson_clearsNotes_whenBlank() {
UUID id = UUID.randomUUID();
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").notes("old notes").build();
when(personRepository.findById(id)).thenReturn(Optional.of(person));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Person result = personService.updatePerson(id, "Anna", "Alt", null, " ", null, null);
assertThat(result.getNotes()).isNull();
}
// ─── updatePerson (birth/death years) ────────────────────────────────────
@Test
@@ -92,7 +118,7 @@ class PersonServiceTest {
when(personRepository.findById(id)).thenReturn(Optional.of(person));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Person result = personService.updatePerson(id, "Anna", "Alt", null, 1890, 1965);
Person result = personService.updatePerson(id, "Anna", "Alt", null, null, 1890, 1965);
assertThat(result.getBirthYear()).isEqualTo(1890);
assertThat(result.getDeathYear()).isEqualTo(1965);
@@ -102,7 +128,7 @@ class PersonServiceTest {
void updatePerson_throwsBadRequest_whenBirthYearAfterDeathYear() {
UUID id = UUID.randomUUID();
assertThatThrownBy(() -> personService.updatePerson(id, "Anna", "Alt", null, 1970, 1950))
assertThatThrownBy(() -> personService.updatePerson(id, "Anna", "Alt", null, null, 1970, 1950))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
@@ -115,7 +141,7 @@ class PersonServiceTest {
when(personRepository.findById(id)).thenReturn(Optional.of(person));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Person result = personService.updatePerson(id, "Anna", "Alt", null, 1900, 1900);
Person result = personService.updatePerson(id, "Anna", "Alt", null, null, 1900, 1900);
assertThat(result.getBirthYear()).isEqualTo(1900);
assertThat(result.getDeathYear()).isEqualTo(1900);

View File

@@ -94,6 +94,40 @@ test.describe('New person', () => {
});
});
test.describe('Person detail — sort toggle', () => {
test('sort toggle changes the button label when person has documents', async ({ page }) => {
await page.goto('/persons');
const firstPerson = page.locator('a[href^="/persons/"]').first();
await firstPerson.click();
await page.waitForSelector('[data-hydrated]');
const sortBtn = page.getByRole('button', { name: /Neueste zuerst|Älteste zuerst/i });
if (await sortBtn.isVisible()) {
await expect(sortBtn).toContainText('Neueste zuerst');
await sortBtn.click();
await expect(sortBtn).toContainText('Älteste zuerst');
await sortBtn.click();
await expect(sortBtn).toContainText('Neueste zuerst');
await page.screenshot({ path: 'test-results/e2e/person-sort-toggle.png' });
}
});
});
test.describe('Person detail — conversations link', () => {
test('has a conversations link that pre-fills the person', async ({ page }) => {
await page.goto('/persons');
// Exclude /persons/new to avoid matching the "New person" button
const firstLink = page.locator('a[href^="/persons/"]:not([href="/persons/new"])').first();
const href = await firstLink.getAttribute('href');
const personId = href!.split('/persons/')[1];
await firstLink.click();
// Use the specific person-detail link text, not the nav "Konversationen" link
const convLink = page.getByRole('link', { name: /Konversationen anzeigen/i });
await expect(convLink).toBeVisible();
await expect(convLink).toHaveAttribute('href', `/conversations?senderId=${personId}`);
});
});
test.describe('Conversations', () => {
test('shows the empty state when no persons are selected', async ({ page }) => {
await page.goto('/conversations');

View File

@@ -111,6 +111,8 @@
"person_btn_merge": "Zusammenführen",
"person_btn_merge_confirm": "Ja, zusammenführen",
"person_merge_warning": "Achtung: Diese Aktion ist nicht rückgängig zu machen.",
"person_label_notes": "Notizen",
"person_placeholder_notes": "Biographische Hinweise, Besonderheiten…",
"person_label_birth_year": "Geburtsjahr",
"person_label_death_year": "Todesjahr",
"person_placeholder_year": "z.B. 1923",

View File

@@ -111,6 +111,8 @@
"person_btn_merge": "Merge",
"person_btn_merge_confirm": "Yes, merge",
"person_merge_warning": "Warning: This action cannot be undone.",
"person_label_notes": "Notes",
"person_placeholder_notes": "Biographical notes, remarks…",
"person_label_birth_year": "Birth year",
"person_label_death_year": "Death year",
"person_placeholder_year": "e.g. 1923",
@@ -180,5 +182,6 @@
"comp_taginput_placeholder_create": "Add tags...",
"comp_taginput_placeholder_filter": "Filter by tags...",
"comp_taginput_remove": "Remove tag",
"comp_taginput_create_hint": "Press Enter to create tag."
"comp_taginput_create_hint": "Press Enter to create tag.",
"person_btn_conversations": "View conversations"
}

View File

@@ -111,6 +111,8 @@
"person_btn_merge": "Fusionar",
"person_btn_merge_confirm": "Sí, fusionar",
"person_merge_warning": "Atención: Esta acción no se puede deshacer.",
"person_label_notes": "Notas",
"person_placeholder_notes": "Notas biográficas, observaciones…",
"person_label_birth_year": "Año de nacimiento",
"person_label_death_year": "Año de fallecimiento",
"person_placeholder_year": "p.ej. 1923",
@@ -180,5 +182,6 @@
"comp_taginput_placeholder_create": "Añadir etiquetas...",
"comp_taginput_placeholder_filter": "Filtrar por etiquetas...",
"comp_taginput_remove": "Eliminar etiqueta",
"comp_taginput_create_hint": "Pulse Enter para crear etiqueta."
"comp_taginput_create_hint": "Pulse Enter para crear etiqueta.",
"person_btn_conversations": "Ver conversaciones"
}

View File

@@ -307,6 +307,7 @@ export interface components {
firstName: string;
lastName: string;
alias?: string;
notes?: string;
birthYear?: number;
deathYear?: number;
};

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { sortDocumentsByDate } from './sort';
const doc = (id: string, documentDate: string | null) =>
({ id, documentDate } as { id: string; documentDate: string | null });
describe('sortDocumentsByDate', () => {
it('sorts DESC by default — newest first', () => {
const docs = [doc('a', '1920-01-01'), doc('b', '1950-06-15'), doc('c', '1935-03-10')];
const result = sortDocumentsByDate(docs, 'DESC');
expect(result.map((d) => d.id)).toEqual(['b', 'c', 'a']);
});
it('sorts ASC — oldest first', () => {
const docs = [doc('a', '1920-01-01'), doc('b', '1950-06-15'), doc('c', '1935-03-10')];
const result = sortDocumentsByDate(docs, 'ASC');
expect(result.map((d) => d.id)).toEqual(['a', 'c', 'b']);
});
it('places documents without a date last in DESC', () => {
const docs = [doc('a', null), doc('b', '1940-01-01'), doc('c', null)];
const result = sortDocumentsByDate(docs, 'DESC');
expect(result[0].id).toBe('b');
expect(result.slice(1).map((d) => d.id)).toContain('a');
expect(result.slice(1).map((d) => d.id)).toContain('c');
});
it('places documents without a date last in ASC', () => {
const docs = [doc('a', null), doc('b', '1940-01-01'), doc('c', null)];
const result = sortDocumentsByDate(docs, 'ASC');
expect(result[0].id).toBe('b');
});
it('does not mutate the original array', () => {
const docs = [doc('a', '1950-01-01'), doc('b', '1920-01-01')];
const original = [...docs];
sortDocumentsByDate(docs, 'ASC');
expect(docs).toEqual(original);
});
it('returns an empty array unchanged', () => {
expect(sortDocumentsByDate([], 'DESC')).toEqual([]);
});
});

View File

@@ -0,0 +1,19 @@
export type SortDir = 'ASC' | 'DESC';
/**
* Returns a new array of documents sorted by documentDate.
* Documents without a date are always placed last, regardless of direction.
*/
export function sortDocumentsByDate<T extends { documentDate?: string | null }>(
docs: T[],
dir: SortDir
): T[] {
return [...docs].sort((a, b) => {
const da = a.documentDate ?? '';
const db = b.documentDate ?? '';
if (!da && !db) return 0;
if (!da) return 1;
if (!db) return -1;
return dir === 'DESC' ? db.localeCompare(da) : da.localeCompare(db);
});
}

View File

@@ -28,6 +28,7 @@ export const actions = {
const firstName = formData.get('firstName')?.toString().trim();
const lastName = formData.get('lastName')?.toString().trim();
const alias = formData.get('alias')?.toString().trim() || undefined;
const notes = formData.get('notes')?.toString().trim() || undefined;
const birthYear = formData.get('birthYear')?.toString().trim() || undefined;
const deathYear = formData.get('deathYear')?.toString().trim() || undefined;
@@ -41,6 +42,7 @@ export const actions = {
body: {
firstName, lastName,
...(alias ? { alias } : {}),
...(notes ? { notes } : {}),
...(birthYear ? { birthYear } : {}),
...(deathYear ? { deathYear } : {})
}

View File

@@ -2,12 +2,16 @@
import { enhance } from '$app/forms';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import { m } from '$lib/paraglide/messages.js';
import { sortDocumentsByDate, type SortDir } from '$lib/utils/sort';
let { data, form } = $props();
const person = $derived(data.person);
const documents = $derived(data.documents);
let sortDir = $state<SortDir>('DESC');
const sortedDocuments = $derived(sortDocumentsByDate(documents, sortDir));
let editMode = $state(false);
let mergeTargetId = $state('');
let showMergeConfirm = $state(false);
@@ -108,6 +112,16 @@
class="block w-full border border-gray-300 rounded px-3 py-2 font-serif text-brand-navy focus:outline-none focus:border-brand-navy"
/>
</div>
<div class="md:col-span-2">
<label for="notes" class="block text-xs font-bold uppercase tracking-widest text-gray-400 mb-1">{m.person_label_notes()}</label>
<textarea
id="notes"
name="notes"
rows="4"
placeholder={m.person_placeholder_notes()}
class="block w-full border border-gray-300 rounded px-3 py-2 font-serif text-brand-navy focus:outline-none focus:border-brand-navy resize-y"
>{person.notes ?? ''}</textarea>
</div>
</div>
<div class="flex gap-3">
@@ -134,10 +148,16 @@
<h1 class="text-4xl font-serif text-brand-navy">
{person.firstName} {person.lastName}
</h1>
<button onclick={() => (editMode = true)} class="ml-4 flex-shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold uppercase tracking-widest border border-gray-300 text-gray-500 rounded hover:border-brand-navy hover:text-brand-navy transition-colors">
<img src="/degruyter-icons/Simple/Small-16px/SVG/Action/Edit-Content-SM.svg" alt="" aria-hidden="true" class="w-3.5 h-3.5" />
{m.btn_edit()}
</button>
<div class="ml-4 flex-shrink-0 flex items-center gap-2">
<a href="/conversations?senderId={person.id}" class="inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 hover:text-brand-navy transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
{m.person_btn_conversations()}
</a>
<button onclick={() => (editMode = true)} class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold uppercase tracking-widest border border-gray-300 text-gray-500 rounded hover:border-brand-navy hover:text-brand-navy transition-colors">
<img src="/degruyter-icons/Simple/Small-16px/SVG/Action/Edit-Content-SM.svg" alt="" aria-hidden="true" class="w-3.5 h-3.5" />
{m.btn_edit()}
</button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
@@ -163,6 +183,13 @@
</span>
</div>
{/if}
{#if person.notes}
<div class="md:col-span-2">
<span class="block text-xs font-bold font-sans text-gray-400 uppercase tracking-widest mb-1">{m.person_label_notes()}</span>
<p class="text-base font-serif text-brand-navy whitespace-pre-wrap">{person.notes}</p>
</div>
{/if}
</div>
</div>
</div>
@@ -237,10 +264,21 @@
<!-- Linked Documents Section -->
<div>
<div class="flex items-center justify-between mb-6 border-b border-brand-navy/10 pb-2">
<h2 class="text-xl font-serif text-brand-navy">{m.person_docs_heading()}</h2>
<span class="bg-brand-navy text-white text-xs font-bold px-2 py-1 rounded-full">
{documents.length}
</span>
<div class="flex items-center gap-3">
<h2 class="text-xl font-serif text-brand-navy">{m.person_docs_heading()}</h2>
<span class="bg-brand-navy text-white text-xs font-bold px-2 py-1 rounded-full">
{documents.length}
</span>
</div>
{#if documents.length > 0}
<button
onclick={() => (sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC')}
class="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-brand-navy transition-colors"
aria-label={m.conv_sort_label()}
>
{sortDir === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}
</button>
{/if}
</div>
{#if documents.length === 0}
@@ -249,7 +287,7 @@
</div>
{:else}
<ul class="space-y-3">
{#each documents as doc}
{#each sortedDocuments as doc}
<li class="group">
<a href="/documents/{doc.id}" class="block bg-white border border-brand-sand p-4 hover:border-brand-navy hover:shadow-md transition-all duration-200">
<div class="flex items-center justify-between">