From 7245571ea8996e2e412010401d5360ee6225a9f8 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 27 May 2026 12:04:14 +0200 Subject: [PATCH] feat(document): edit document date precision, end and raw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the edit-form date-precision controls to WhoWhenSection: a labelled precision constrains nothing" note for the persistence half. Server-side enum/end>=start validation remains #671's scope. Refs #666 Co-Authored-By: Claude Opus 4.7 --- .../document/DocumentService.java | 3 + .../document/DocumentServiceTest.java | 20 +++++ .../lib/document/DocumentEditLayout.svelte | 10 +++ .../src/lib/document/WhoWhenSection.svelte | 74 +++++++++++++++++++ .../document/WhoWhenSection.svelte.test.ts | 30 ++++++++ 5 files changed, 137 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java index edeedee6..19961140 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -378,6 +378,9 @@ public class DocumentService { // 1. Einfache Felder Update doc.setTitle(dto.getTitle()); doc.setDocumentDate(dto.getDocumentDate()); + doc.setMetaDatePrecision(dto.getMetaDatePrecision()); + doc.setMetaDateEnd(dto.getMetaDateEnd()); + doc.setMetaDateRaw(dto.getMetaDateRaw()); doc.setLocation(dto.getLocation()); doc.setTranscription(dto.getTranscription()); doc.setSummary(dto.getSummary()); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java index 8ef7a6f2..d603608e 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java @@ -144,6 +144,26 @@ class DocumentServiceTest { assertThat(doc.getArchiveFolder()).isEqualTo("Mappe B"); } + @Test + void updateDocument_persistsDatePrecisionEndAndRaw() throws Exception { + UUID id = UUID.randomUUID(); + Document doc = Document.builder().id(id).receivers(new HashSet<>()).tags(new HashSet<>()).build(); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + when(documentRepository.save(any())).thenReturn(doc); + + DocumentUpdateDTO dto = new DocumentUpdateDTO(); + dto.setDocumentDate(LocalDate.of(1917, 1, 10)); + dto.setMetaDatePrecision(DatePrecision.RANGE); + dto.setMetaDateEnd(LocalDate.of(1917, 1, 11)); + dto.setMetaDateRaw("10.–11. Januar 1917"); + + documentService.updateDocument(id, dto, null, null); + + assertThat(doc.getMetaDatePrecision()).isEqualTo(DatePrecision.RANGE); + assertThat(doc.getMetaDateEnd()).isEqualTo(LocalDate.of(1917, 1, 11)); + assertThat(doc.getMetaDateRaw()).isEqualTo("10.–11. Januar 1917"); + } + // ─── deleteTagCascading ─────────────────────────────────────────────────── @Test diff --git a/frontend/src/lib/document/DocumentEditLayout.svelte b/frontend/src/lib/document/DocumentEditLayout.svelte index 52df8248..a451da48 100644 --- a/frontend/src/lib/document/DocumentEditLayout.svelte +++ b/frontend/src/lib/document/DocumentEditLayout.svelte @@ -13,6 +13,7 @@ import WhoWhenSection from '$lib/document/WhoWhenSection.svelte'; import DescriptionSection from '$lib/document/DescriptionSection.svelte'; import type { Tag } from '$lib/tag/TagInput.svelte'; import type { components } from '$lib/generated/api'; +import type { DatePrecision } from '$lib/shared/utils/documentDate'; type Person = components['schemas']['Person']; type Doc = components['schemas']['Document']; @@ -26,6 +27,8 @@ let { senderId = $bindable(''), selectedReceivers = $bindable([]), dateIso = $bindable(''), + datePrecision = $bindable('DAY'), + dateEndIso = $bindable(''), currentTitle = $bindable(''), topbar, actionbar @@ -38,6 +41,8 @@ let { senderId?: string; selectedReceivers?: Person[]; dateIso?: string; + datePrecision?: DatePrecision; + dateEndIso?: string; currentTitle?: string; topbar: Snippet; actionbar: Snippet; @@ -47,6 +52,8 @@ tags = untrack(() => (doc.tags as Tag[]) ?? []); senderId = untrack(() => doc.sender?.id ?? ''); selectedReceivers = untrack(() => (doc.receivers as Person[]) ?? []); dateIso = untrack(() => doc.documentDate ?? ''); +datePrecision = untrack(() => doc.metaDatePrecision ?? (doc.documentDate ? 'DAY' : 'UNKNOWN')); +dateEndIso = untrack(() => doc.metaDateEnd ?? ''); currentTitle = untrack(() => doc.title ?? ''); const fileLoader = createFileLoader(); @@ -199,6 +206,9 @@ async function handleReplaceFile(e: Event) { bind:senderId={senderId} bind:selectedReceivers={selectedReceivers} bind:dateIso={dateIso} + bind:precision={datePrecision} + bind:endDateIso={dateEndIso} + rawDate={doc.metaDateRaw ?? ''} initialDateIso={doc.documentDate ?? ''} initialLocation={doc.location ?? ''} initialSenderName={doc.sender?.displayName ?? ''} diff --git a/frontend/src/lib/document/WhoWhenSection.svelte b/frontend/src/lib/document/WhoWhenSection.svelte index 988bd18e..bd5ddd80 100644 --- a/frontend/src/lib/document/WhoWhenSection.svelte +++ b/frontend/src/lib/document/WhoWhenSection.svelte @@ -6,6 +6,7 @@ import FieldLabelBadge from '$lib/shared/primitives/FieldLabelBadge.svelte'; import { isoToGerman, handleGermanDateInput } from '$lib/shared/utils/date'; import { m } from '$lib/paraglide/messages.js'; import type { components } from '$lib/generated/api'; +import type { DatePrecision } from '$lib/shared/utils/documentDate'; type Person = components['schemas']['Person']; @@ -13,6 +14,9 @@ let { senderId = $bindable(''), selectedReceivers = $bindable([]), dateIso = $bindable(''), + precision = $bindable('DAY'), + endDateIso = $bindable(''), + rawDate = '', initialDateIso = '', initialLocation = '', initialSenderName = '', @@ -24,6 +28,9 @@ let { senderId?: string; selectedReceivers?: Person[]; dateIso?: string; + precision?: DatePrecision; + endDateIso?: string; + rawDate?: string; initialDateIso?: string; initialLocation?: string; initialSenderName?: string; @@ -33,11 +40,24 @@ let { editMode?: boolean; } = $props(); +const PRECISIONS: { value: DatePrecision; label: () => string }[] = [ + { value: 'DAY', label: m.date_precision_option_day }, + { value: 'MONTH', label: m.date_precision_option_month }, + { value: 'SEASON', label: m.date_precision_option_season }, + { value: 'YEAR', label: m.date_precision_option_year }, + { value: 'RANGE', label: m.date_precision_option_range }, + { value: 'APPROX', label: m.date_precision_option_approx }, + { value: 'UNKNOWN', label: m.date_precision_option_unknown } +]; + +const showEndDate = $derived(precision === 'RANGE'); + // dateDisplay seeds from the bindable's value or initialDateIso once at mount // and is then user-driven. onMount runs exactly once, so this never stomps // the parent's dateIso on a later prop change. let dateDisplay = $state(''); let dateDirty = $state(false); +let endDisplay = $state(''); onMount(() => { const seed = dateIso || initialDateIso; @@ -45,6 +65,7 @@ onMount(() => { dateDisplay = isoToGerman(seed); if (!dateIso) dateIso = seed; } + if (endDateIso) endDisplay = isoToGerman(endDateIso); }); const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === ''); @@ -56,6 +77,12 @@ function handleDateInput(e: Event) { dateDirty = true; } +function handleEndDateInput(e: Event) { + const result = handleGermanDateInput(e); + endDisplay = result.display; + endDateIso = result.iso; +} + $effect(() => { const suggested = suggestedDateIso; if (suggested && !untrack(() => dateDirty)) { @@ -96,6 +123,53 @@ $effect(() => {

{m.form_date_error()}

{/if} + +
+ + +
+ + +
+ {#if showEndDate} +
+ + +
+ {/if} +
+ + + + {#if rawDate && rawDate.trim().length > 0} +
+

{m.date_original_label()}

+

{rawDate}

+ +
+ {/if} {/if} diff --git a/frontend/src/lib/document/WhoWhenSection.svelte.test.ts b/frontend/src/lib/document/WhoWhenSection.svelte.test.ts index a48ac430..d3a05147 100644 --- a/frontend/src/lib/document/WhoWhenSection.svelte.test.ts +++ b/frontend/src/lib/document/WhoWhenSection.svelte.test.ts @@ -72,3 +72,33 @@ describe('WhoWhenSection — date input behavior', () => { expect(label?.textContent).toContain('*'); }); }); + +describe('WhoWhenSection — precision controls', () => { + it('renders a labelled precision select', async () => { + render(WhoWhenSection, {}); + + const label = document.querySelector('label[for="metaDatePrecision"]'); + const select = document.querySelector('select#metaDatePrecision[name="metaDatePrecision"]'); + expect(label).not.toBeNull(); + expect(select).not.toBeNull(); + }); + + it('hides the end-date field unless precision is RANGE', async () => { + render(WhoWhenSection, { precision: 'DAY' }); + expect(document.querySelector('input#metaDateEnd')).toBeNull(); + }); + + it('reveals the end-date field when precision is RANGE', async () => { + render(WhoWhenSection, { precision: 'RANGE' }); + expect(document.querySelector('input#metaDateEnd')).not.toBeNull(); + }); + + it('renders the raw cell as static text (not an editable input) and escapes it', async () => { + render(WhoWhenSection, { rawDate: 'Sommer 1916' }); + const raw = document.querySelector('[data-testid="who-when-raw"]'); + expect(raw).not.toBeNull(); + // Verbatim shown as escaped text; no injected element. + expect(raw?.textContent).toContain('Sommer 1916'); + expect(raw?.querySelector('b')).toBeNull(); + }); +});