From 654ac1478c2ed5dd4b4d8c0a3275e4cee65aace1 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 1 Jun 2026 09:27:57 +0200 Subject: [PATCH] feat(document): surface end-before-start inline on the date form (#678) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an endBeforeStart $derived to WhoWhenSection (lexicographic ISO compare, no Date object) that renders an inline error on the end-date field — border-red-400, aria-invalid, aria-describedby, and a #end-date-error

inside the existing aria-live region — with a ⚠ glyph so the cue is not colour-alone (WCAG 1.4.1). Save is not disabled; the server stays the gate. Wire ErrorCode INVALID_DATE_RANGE through errors.ts getErrorMessage and add the single key error_invalid_date_range to de/en/es, so the same translated string is used inline (client) and via getErrorMessage (server fallback). Co-Authored-By: Claude Opus 4.8 --- frontend/messages/de.json | 1 + frontend/messages/en.json | 1 + frontend/messages/es.json | 1 + .../src/lib/document/WhoWhenSection.svelte | 20 +++++++- .../document/WhoWhenSection.svelte.test.ts | 46 +++++++++++++++++++ frontend/src/lib/shared/errors.ts | 3 ++ 6 files changed, 71 insertions(+), 1 deletion(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 14269eed..5590c6b7 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -655,6 +655,7 @@ "person_alias_btn_delete": "Entfernen", "error_alias_not_found": "Der Namensalias wurde nicht gefunden.", "error_invalid_person_type": "Der angegebene Personentyp ist ungültig.", + "error_invalid_date_range": "Das Enddatum darf nicht vor dem Startdatum liegen.", "validation_last_name_required": "Nachname ist Pflichtfeld.", "validation_first_name_required": "Vorname ist Pflichtfeld.", "error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index b7de0948..5b7c2698 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -655,6 +655,7 @@ "person_alias_btn_delete": "Remove", "error_alias_not_found": "The name alias was not found.", "error_invalid_person_type": "The specified person type is not valid.", + "error_invalid_date_range": "The end date must not be before the start date.", "validation_last_name_required": "Last name is required.", "validation_first_name_required": "First name is required.", "error_ocr_service_unavailable": "The OCR service is not available.", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index ee584c40..4e856892 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -655,6 +655,7 @@ "person_alias_btn_delete": "Eliminar", "error_alias_not_found": "No se encontro el alias de nombre.", "error_invalid_person_type": "El tipo de persona especificado no es válido.", + "error_invalid_date_range": "La fecha final no puede ser anterior a la inicial.", "validation_last_name_required": "El apellido es obligatorio.", "validation_first_name_required": "El nombre es obligatorio.", "error_ocr_service_unavailable": "El servicio OCR no está disponible.", diff --git a/frontend/src/lib/document/WhoWhenSection.svelte b/frontend/src/lib/document/WhoWhenSection.svelte index 1a312e63..8c49a72b 100644 --- a/frontend/src/lib/document/WhoWhenSection.svelte +++ b/frontend/src/lib/document/WhoWhenSection.svelte @@ -70,6 +70,13 @@ onMount(() => { const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === ''); +// Inline mirror of the server guard (#678). ISO YYYY-MM-DD strings compare +// lexicographically, so no Date object is needed. Server stays the gate — +// this only surfaces the error early; it never disables Save. +const endBeforeStart = $derived( + showEndDate && endDateIso !== '' && dateIso !== '' && endDateIso < dateIso +); + function handleDateInput(e: Event) { const result = handleGermanDateInput(e); dateDisplay = result.display; @@ -155,8 +162,19 @@ $effect(() => { oninput={handleEndDateInput} placeholder={m.form_placeholder_date()} maxlength="10" - class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" + aria-invalid={endBeforeStart ? 'true' : undefined} + aria-describedby={endBeforeStart ? 'end-date-error' : undefined} + class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm + {endBeforeStart + ? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500' + : 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}" /> + {#if endBeforeStart} + +

+ {m.error_invalid_date_range()} +

+ {/if} {/if} diff --git a/frontend/src/lib/document/WhoWhenSection.svelte.test.ts b/frontend/src/lib/document/WhoWhenSection.svelte.test.ts index d3a05147..f2d7746f 100644 --- a/frontend/src/lib/document/WhoWhenSection.svelte.test.ts +++ b/frontend/src/lib/document/WhoWhenSection.svelte.test.ts @@ -102,3 +102,49 @@ describe('WhoWhenSection — precision controls', () => { expect(raw?.querySelector('b')).toBeNull(); }); }); + +describe('WhoWhenSection — end-before-start inline validation (#678)', () => { + it('shows an inline error on the end-date field when end is before start (AC1)', async () => { + render(WhoWhenSection, { + precision: 'RANGE', + dateIso: '1917-01-11', + endDateIso: '1917-01-10' + }); + + const end = document.querySelector('input#metaDateEnd') as HTMLInputElement; + await vi.waitFor(() => { + expect(document.querySelector('#end-date-error')).not.toBeNull(); + expect(end.getAttribute('aria-invalid')).toBe('true'); + expect(end.className).toContain('border-red-400'); + }); + }); + + it('clears the inline error once the end date is corrected, without reload (AC5)', async () => { + render(WhoWhenSection, { + precision: 'RANGE', + dateIso: '1917-01-11', + endDateIso: '1917-01-10' + }); + + await vi.waitFor(() => expect(document.querySelector('#end-date-error')).not.toBeNull()); + + const end = document.querySelector('input#metaDateEnd') as HTMLInputElement; + end.value = '12.01.1917'; // now after the start + end.dispatchEvent(new Event('input', { bubbles: true })); + + await vi.waitFor(() => { + expect(document.querySelector('#end-date-error')).toBeNull(); + expect(end.getAttribute('aria-invalid')).not.toBe('true'); + }); + }); + + it('does not show the inline error when precision is not RANGE', async () => { + render(WhoWhenSection, { + precision: 'DAY', + dateIso: '1917-01-11', + endDateIso: '1917-01-10' + }); + + expect(document.querySelector('#end-date-error')).toBeNull(); + }); +}); diff --git a/frontend/src/lib/shared/errors.ts b/frontend/src/lib/shared/errors.ts index dcdb9f25..6efec2a7 100644 --- a/frontend/src/lib/shared/errors.ts +++ b/frontend/src/lib/shared/errors.ts @@ -8,6 +8,7 @@ export type ErrorCode = | 'PERSON_NOT_FOUND' | 'ALIAS_NOT_FOUND' | 'INVALID_PERSON_TYPE' + | 'INVALID_DATE_RANGE' | 'DOCUMENT_NOT_FOUND' | 'DOCUMENT_NO_FILE' | 'FILE_NOT_FOUND' @@ -87,6 +88,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string { return m.error_alias_not_found(); case 'INVALID_PERSON_TYPE': return m.error_invalid_person_type(); + case 'INVALID_DATE_RANGE': + return m.error_invalid_date_range(); case 'DOCUMENT_NOT_FOUND': return m.error_document_not_found(); case 'DOCUMENT_NO_FILE':