diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java index a536533a..4db84bde 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java @@ -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") diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/Person.java b/backend/src/main/java/org/raddatz/familienarchiv/model/Person.java index 7d40d68e..ce2595b5 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/Person.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/Person.java @@ -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; } \ No newline at end of file diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java index 56cfcaed..ae555086 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java @@ -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); diff --git a/backend/src/main/resources/db/migration/V5__add_notes_to_persons.sql b/backend/src/main/resources/db/migration/V5__add_notes_to_persons.sql new file mode 100644 index 00000000..8e6514f1 --- /dev/null +++ b/backend/src/main/resources/db/migration/V5__add_notes_to_persons.sql @@ -0,0 +1 @@ +ALTER TABLE persons ADD COLUMN notes TEXT; diff --git a/backend/src/main/resources/db/migration/V5__add_birth_death_years_to_persons.sql b/backend/src/main/resources/db/migration/V6__add_birth_death_years_to_persons.sql similarity index 100% rename from backend/src/main/resources/db/migration/V5__add_birth_death_years_to_persons.sql rename to backend/src/main/resources/db/migration/V6__add_birth_death_years_to_persons.sql diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java index 5691d583..654df2ef 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java @@ -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); diff --git a/frontend/e2e/persons.spec.ts b/frontend/e2e/persons.spec.ts index bff25d38..87701eb8 100644 --- a/frontend/e2e/persons.spec.ts +++ b/frontend/e2e/persons.spec.ts @@ -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'); diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 6ca7097b..fec2ab78 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -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", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index d19d9631..ea7937aa 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -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" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 04d02a68..8e3dd5cc 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -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" } diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 99e96c38..b0eedc49 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -307,6 +307,7 @@ export interface components { firstName: string; lastName: string; alias?: string; + notes?: string; birthYear?: number; deathYear?: number; }; diff --git a/frontend/src/lib/utils/sort.spec.ts b/frontend/src/lib/utils/sort.spec.ts new file mode 100644 index 00000000..488fd8d8 --- /dev/null +++ b/frontend/src/lib/utils/sort.spec.ts @@ -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([]); + }); +}); diff --git a/frontend/src/lib/utils/sort.ts b/frontend/src/lib/utils/sort.ts new file mode 100644 index 00000000..eccd573a --- /dev/null +++ b/frontend/src/lib/utils/sort.ts @@ -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( + 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); + }); +} diff --git a/frontend/src/routes/persons/[id]/+page.server.ts b/frontend/src/routes/persons/[id]/+page.server.ts index 0d1385e8..1dc07294 100644 --- a/frontend/src/routes/persons/[id]/+page.server.ts +++ b/frontend/src/routes/persons/[id]/+page.server.ts @@ -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 } : {}) } diff --git a/frontend/src/routes/persons/[id]/+page.svelte b/frontend/src/routes/persons/[id]/+page.svelte index 06602bde..605c848b 100644 --- a/frontend/src/routes/persons/[id]/+page.svelte +++ b/frontend/src/routes/persons/[id]/+page.svelte @@ -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('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" /> +
+ + +
@@ -134,10 +148,16 @@

{person.firstName} {person.lastName}

- +
+ + + {m.person_btn_conversations()} + + +
@@ -163,6 +183,13 @@
{/if} + + {#if person.notes} +
+ {m.person_label_notes()} +

{person.notes}

+
+ {/if} @@ -237,10 +264,21 @@
-

{m.person_docs_heading()}

- - {documents.length} - +
+

{m.person_docs_heading()}

+ + {documents.length} + +
+ {#if documents.length > 0} + + {/if}
{#if documents.length === 0} @@ -249,7 +287,7 @@
{:else}