From 23f6bc284d79cf353c149a146c792ba2183d1226 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 14 Jun 2026 09:11:20 +0200 Subject: [PATCH] fix(timeline): validate RANGE end-date client-side with a field-level error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A RANGE event with a blank end date passed validateEventForm and reached the backend, which 400s with a generic INVALID_DATE_RANGE mapped to "end must not be before start" — wrong for a missing end date, and shown only as a top-of-form alert. Validate it before the API call and surface a dedicated event_editor_end_date_required message on the end-date field via a new DatePrecisionField endDateError prop (defaults '', so the document form is unchanged). Co-Authored-By: Claude Opus 4.8 --- frontend/messages/de.json | 1 + frontend/messages/en.json | 1 + frontend/messages/es.json | 1 + .../primitives/DatePrecisionField.svelte | 16 +++++++++++--- frontend/src/lib/timeline/EventForm.svelte | 5 +++++ .../src/lib/timeline/EventForm.svelte.spec.ts | 15 +++++++++++++ frontend/src/lib/timeline/eventFormServer.ts | 18 ++++++++++------ .../zeitstrahl/events/new/page.server.spec.ts | 21 +++++++++++++++++++ 8 files changed, 69 insertions(+), 9 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index b4e6ba98..97f2ad4a 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -1056,6 +1056,7 @@ "event_editor_title_placeholder": "Titel des Ereignisses", "event_editor_title_required": "Bitte einen Titel eingeben.", "event_editor_date_required": "Bitte ein Datum eingeben.", + "event_editor_end_date_required": "Bitte ein Enddatum eingeben.", "event_editor_type_label": "Typ", "event_editor_persons_label": "Personen", "event_editor_documents_label": "Briefe", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 76fbba6b..e0adb2ed 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1056,6 +1056,7 @@ "event_editor_title_placeholder": "Event title", "event_editor_title_required": "Please enter a title.", "event_editor_date_required": "Please enter a date.", + "event_editor_end_date_required": "Please enter an end date.", "event_editor_type_label": "Type", "event_editor_persons_label": "People", "event_editor_documents_label": "Letters", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 11d0f034..e67a3578 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1056,6 +1056,7 @@ "event_editor_title_placeholder": "Título del evento", "event_editor_title_required": "Por favor, introduzca un título.", "event_editor_date_required": "Por favor, introduzca una fecha.", + "event_editor_end_date_required": "Por favor, introduzca una fecha de fin.", "event_editor_type_label": "Tipo", "event_editor_persons_label": "Personas", "event_editor_documents_label": "Cartas", diff --git a/frontend/src/lib/shared/primitives/DatePrecisionField.svelte b/frontend/src/lib/shared/primitives/DatePrecisionField.svelte index c65d294e..116d3b22 100644 --- a/frontend/src/lib/shared/primitives/DatePrecisionField.svelte +++ b/frontend/src/lib/shared/primitives/DatePrecisionField.svelte @@ -36,6 +36,7 @@ let { dateLabel = m.form_label_date(), dateRequired = true, dateError = '', + endDateError = '', onchange = undefined, dateTestId = undefined, precisionTestId = undefined, @@ -53,6 +54,8 @@ let { dateRequired?: boolean; /** Server-side date error (e.g. blank required field) wired to the field's aria-invalid. */ dateError?: string; + /** Server-side end-date error (e.g. RANGE without an end date) wired to the end field. */ + endDateError?: string; /** Called on any user edit (date, precision, end-date) — lets a parent track dirtiness. */ onchange?: () => void; dateTestId?: string; @@ -99,6 +102,9 @@ const dateFieldInvalid = $derived(dateInvalid || dateError.length > 0); const endBeforeStart = $derived( showEndDate && endDateIso !== '' && dateIso !== '' && endDateIso < dateIso ); +// Either the inline end-before-start cue or a server-provided end-date error +// (e.g. a RANGE event missing its end date) marks the end field invalid. +const endDateFieldInvalid = $derived(endBeforeStart || endDateError.length > 0); function handleDateInput(e: Event) { const result = handleGermanDateInput(e); @@ -190,10 +196,10 @@ $effect(() => { oninput={handleEndDateInput} placeholder={m.form_placeholder_date()} maxlength="10" - aria-invalid={endBeforeStart ? 'true' : undefined} - aria-describedby={endBeforeStart ? `${dateInputName}-end-error` : undefined} + aria-invalid={endDateFieldInvalid ? 'true' : undefined} + aria-describedby={endDateFieldInvalid ? `${dateInputName}-end-error` : undefined} class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm - {endBeforeStart + {endDateFieldInvalid ? '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'}" /> @@ -202,6 +208,10 @@ $effect(() => {

{m.error_invalid_date_range()}

+ {:else if endDateError} +

+ {endDateError} +

{/if} {/if} diff --git a/frontend/src/lib/timeline/EventForm.svelte b/frontend/src/lib/timeline/EventForm.svelte index e329e231..9cf675d1 100644 --- a/frontend/src/lib/timeline/EventForm.svelte +++ b/frontend/src/lib/timeline/EventForm.svelte @@ -25,6 +25,7 @@ interface FormResult { error?: string; titleError?: string; dateError?: string; + endDateError?: string; title?: string; description?: string; type?: string; @@ -104,6 +105,9 @@ const titleError = $derived( titleEmpty && (titleTouched || !!form?.titleError) ? m.event_editor_title_required() : '' ); const dateError = $derived(dateIso ? '' : (form?.dateError ?? '')); +// Only meaningful for RANGE; clears as soon as an end date is entered. The +// end-date field is hidden off-RANGE, so a stale value never renders. +const endDateError = $derived(endDateIso ? '' : (form?.endDateError ?? '')); beforeNavigate(({ cancel }) => { if (dirty && !submitting) { @@ -224,6 +228,7 @@ function markDirty() { precisionInputName="precision" dateLabel={m.form_label_date()} dateError={dateError} + endDateError={endDateError} onchange={markDirty} dateTestId="event-date" precisionTestId="event-precision" diff --git a/frontend/src/lib/timeline/EventForm.svelte.spec.ts b/frontend/src/lib/timeline/EventForm.svelte.spec.ts index 99180218..2962df86 100644 --- a/frontend/src/lib/timeline/EventForm.svelte.spec.ts +++ b/frontend/src/lib/timeline/EventForm.svelte.spec.ts @@ -148,6 +148,21 @@ describe('EventForm — seeds the When-section from the fail payload (review #2 }); }); +describe('EventForm — RANGE end-date required error wired per-field (review #3)', () => { + it('shows the end-date required message on the end-date field, marked invalid', async () => { + renderForm({ + form: { + precision: 'RANGE', + eventDate: '1925-04-01', + endDateError: 'Bitte ein Enddatum eingeben.' + } + }); + await expect.element(page.getByText('Bitte ein Enddatum eingeben.')).toBeInTheDocument(); + const endInput = document.querySelector('#eventDateEnd') as HTMLInputElement; + expect(endInput.getAttribute('aria-invalid')).toBe('true'); + }); +}); + describe('EventForm — server date error wired per-field (REQ-011)', () => { it('marks the date field aria-invalid and shows the message on a server date error', async () => { renderForm({ form: { dateError: 'Bitte ein Datum eingeben.' } }); diff --git a/frontend/src/lib/timeline/eventFormServer.ts b/frontend/src/lib/timeline/eventFormServer.ts index b633835e..e281e86d 100644 --- a/frontend/src/lib/timeline/eventFormServer.ts +++ b/frontend/src/lib/timeline/eventFormServer.ts @@ -71,17 +71,23 @@ export function parseEventForm(formData: FormData): ParsedEventForm { } /** - * Returns both failing required-field errors (title + date) simultaneously, or - * null when the form is valid. The route owns the `fail(400)` so it can enrich - * the payload with the preserved field values and rehydrated picker selections. + * Returns the failing required-field errors (title + date + RANGE end-date) + * simultaneously, or null when the form is valid. The route owns the `fail(400)` + * so it can enrich the payload with the preserved field values and rehydrated + * picker selections. */ export function validateEventForm( parsed: ParsedEventForm -): { titleError: string; dateError: string } | null { +): { titleError: string; dateError: string; endDateError: string } | null { const titleError = parsed.title.length === 0 ? m.event_editor_title_required() : ''; const dateError = parsed.eventDate.length === 0 ? m.event_editor_date_required() : ''; - if (!titleError && !dateError) return null; - return { titleError, dateError }; + // A RANGE event requires an end date. Catch it here so it never reaches the + // backend, which rejects with a generic INVALID_DATE_RANGE mapped to the wrong + // "end before start" message and no field-level cue. + const endDateError = + parsed.precision === 'RANGE' && !parsed.eventDateEnd ? m.event_editor_end_date_required() : ''; + if (!titleError && !dateError && !endDateError) return null; + return { titleError, dateError, endDateError }; } /** The entered field values echoed back in every `fail(...)` so the form re-renders without loss. */ diff --git a/frontend/src/routes/zeitstrahl/events/new/page.server.spec.ts b/frontend/src/routes/zeitstrahl/events/new/page.server.spec.ts index a6bbb02b..487d091a 100644 --- a/frontend/src/routes/zeitstrahl/events/new/page.server.spec.ts +++ b/frontend/src/routes/zeitstrahl/events/new/page.server.spec.ts @@ -208,6 +208,27 @@ describe('zeitstrahl/events/new save action (REQ-004/009/010/015)', () => { expect(failData(result).eventDateEnd).toBe('1944-03-14'); }); + it('rejects a RANGE event with no end date before calling the API (review #3)', async () => { + // Without this guard the body reaches the backend, which 400s with a generic + // INVALID_DATE_RANGE mapped to the wrong "end before start" message. + const post = vi.fn(); + vi.mocked(createApiClient).mockReturnValue({ POST: post, GET: vi.fn() } as never); + + const result = await actions.save( + saveEvent({ + title: 'Umzug', + type: 'PERSONAL', + eventDate: '1925-04-01', + precision: 'RANGE' + // eventDateEnd intentionally omitted + }) + ); + + expect(post).not.toHaveBeenCalled(); + expect(result).toMatchObject({ status: 400 }); + expect(failData(result).endDateError).toBeTruthy(); + }); + it('surfaces both title and date errors when both blank (REQ-011)', async () => { vi.mocked(createApiClient).mockReturnValue({ POST: vi.fn() } as never); const result = await actions.save(saveEvent({ title: '', type: 'PERSONAL', eventDate: '' }));