Compare commits

...

29 Commits

Author SHA1 Message Date
Marcel
bc02d22270 refactor(timeline): drop EventForm's redundant type state
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m13s
CI / OCR Service Tests (pull_request) Successful in 27s
CI / Backend Unit Tests (pull_request) Successful in 5m1s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
SDD Gate / RTM Check (pull_request) Successful in 15s
SDD Gate / Contract Validate (pull_request) Successful in 24s
SDD Gate / Constitution Impact (pull_request) Successful in 19s
The submitted type comes from EventTypeSelect's own hidden input; EventForm's
`type` $state was only read to seed `value=` and the onchange reassignment
had no downstream reader. Inline the seed expression and pass markDirty
directly, removing the second source of truth.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:13:17 +02:00
Marcel
23f6bc284d fix(timeline): validate RANGE end-date client-side with a field-level error
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 <noreply@anthropic.com>
2026-06-14 09:11:20 +02:00
Marcel
9f2ae7bd2e fix(timeline): preserve date/precision/end across a fail(400)
preservedFormFields echoed back only title/type/description/pickers, and
EventForm seeded dateIso/precision/endDateIso solely from `event` (undefined
on /new). So a no-JS validation-error reload silently dropped the entire
When-section the curator had entered, while every other field survived.

Echo eventDate/precision/eventDateEnd in the fail payload and seed the date
controls from `form` ahead of `event`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:06:21 +02:00
Marcel
4d5fa7a26f fix(timeline): clear required-field errors when the field is corrected
titleError/dateError short-circuited on the server fail payload
(`form?.titleError ?? …`, `form?.dateError ?? ''`), so after a fail(400)
the red border and message stuck until the next submit even once the user
typed a valid value. Derive both from the current field value instead: the
server error still seeds the message, but a non-empty title/date clears it
immediately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:03:11 +02:00
Marcel
5f2cf5f2c2 fix(timeline): gate event delete behind the confirm dialog
The delete <form> combined onsubmit={confirmDelete} with use:enhance.
SvelteKit's enhance ignores event.defaultPrevented, so the DELETE fired on
the bare click — before the dialog was answered — and the post-DELETE
redirect ran regardless of the user's choice. Reading getConfirmService()
lazily inside the handler also threw (Svelte context is init-only), so the
dialog never appeared even with <ConfirmDialog> mounted.

Capture confirm at init and run the gate inside the enhance submit phase,
calling cancel() on "no". Clear dirty in the result callback so the
beforeNavigate guard no longer prompts "unsaved changes" on the post-delete
redirect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:00:45 +02:00
Marcel
b8c8fcb1fb fix(timeline): cancel blank-title event save instead of posting
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 5m35s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 6m14s
CI / fail2ban Regex (pull_request) Successful in 53s
CI / Semgrep Security Scan (pull_request) Successful in 24s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
SDD Gate / RTM Check (pull_request) Successful in 16s
SDD Gate / Contract Validate (pull_request) Successful in 28s
SDD Gate / Constitution Impact (pull_request) Successful in 17s
The EventForm onsubmit guard called e.preventDefault() on a blank title,
but use:enhance ignores defaultPrevented (forms.js only bails on cancel()),
so a blank-title Save still fired a network POST. In a component unit test
the resulting update() -> applyAction() dereferenced an undefined root
($set on undefined), surfacing as an unhandled rejection.

Move the guard into the enhance submit phase and call cancel() so the POST
is actually stopped; the server still owns the authoritative fail(400).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 08:34:59 +02:00
Marcel
6150fc7be5 fix(timeline): track form dirtiness via change callbacks, not an $effect
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 4m17s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 5m23s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 26s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
SDD Gate / RTM Check (pull_request) Successful in 16s
SDD Gate / Contract Validate (pull_request) Successful in 25s
SDD Gate / Constitution Impact (pull_request) Successful in 17s
The round-2 dirty-guard used an $effect that both read and wrote its `dirtyArmed`
$state, so the self-write re-triggered the effect and `dirty` flipped true on
mount — the beforeNavigate confirm then fired on every navigation away from an
untouched form (caught by the round-3 clean-agent review + the Svelte autofixer,
which flags assigning state inside $effect).

Replace it with the component's existing idiomatic pattern: DatePrecisionField,
PersonMultiSelect, and DocumentMultiSelect each gain an optional `onchange`
callback fired on a real user edit, and EventForm passes `markDirty` to all
three. Now date/precision/end-date and picker add/remove mark the form dirty
exactly like title/type/description already did — no effect, no mount-timing
trap. The new props are optional, so the other consumers (WhoWhenSection, the
document forms) are unaffected. Svelte autofixer: clean.

Addresses PR #832 review (round-3 clean-agent concern).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 00:55:34 +02:00
Marcel
0862d43ba3 fix(timeline): mark the event form dirty on date/precision/picker changes
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 4m53s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 5m46s
CI / fail2ban Regex (pull_request) Failing after 46s
CI / Semgrep Security Scan (pull_request) Successful in 25s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
SDD Gate / RTM Check (pull_request) Successful in 17s
SDD Gate / Contract Validate (pull_request) Successful in 24s
SDD Gate / Constitution Impact (pull_request) Successful in 19s
The beforeNavigate unsaved-changes guard only fired for title/type/description
(their oninput/onchange hooks set `dirty`). Editing only the date, precision,
end-date, or the linked persons/documents left `dirty` false, so a curator could
navigate away and silently lose those edits — defeating the guard for the senior
author audience. Add an $effect that watches those values and marks dirty on any
change after the initial prop snapshot (first run only arms the watcher).

Addresses PR #832 review (round-2 clean-agent concern).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 00:44:45 +02:00
Marcel
9cb856b376 test(e2e): update document-title-autosync precision selector to new id
DatePrecisionField derives the precision select's id from dateInputName, so the
document form's id moved from #metaDatePrecision to #documentDatePrecision (the
name attribute is unchanged). This maintained E2E still queried the old id and
would fail when run. Not CI-gated, but a real silent breakage.

Addresses PR #832 review (round-2 clean-agent out-of-diff finding).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 00:44:25 +02:00
Marcel
d330510777 test(document): update WhoWhenSection.test ids after DatePrecisionField extraction
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m58s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 5m32s
CI / fail2ban Regex (pull_request) Successful in 55s
CI / Semgrep Security Scan (pull_request) Successful in 32s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
SDD Gate / RTM Check (pull_request) Successful in 16s
SDD Gate / Contract Validate (pull_request) Successful in 28s
SDD Gate / Constitution Impact (pull_request) Successful in 21s
The DatePrecisionField extraction derives element ids from dateInputName, so the
document form's precision/end-date ids changed (metaDatePrecision →
documentDatePrecision, metaDateEnd → documentDateEnd, date-error →
documentDate-error, end-date-error → documentDate-end-error). The name attributes
are unchanged, so form submission is unaffected — but the pre-existing
WhoWhenSection.svelte.test.ts (a separate file from the .spec.ts) still queried
the old ids and was failing 5 assertions in CI's client project. It wasn't in
the PR diff, so the multi-persona review missed it. Re-point the selectors.

Addresses PR #832 review (round-1 clean-agent blocker).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 00:39:38 +02:00
Marcel
719274ef88 docs(permissions): note requireWriteAll can replace the inline guard elsewhere
Some checks failed
CI / Semgrep Security Scan (pull_request) Successful in 25s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
SDD Gate / RTM Check (pull_request) Successful in 14s
SDD Gate / Contract Validate (pull_request) Successful in 23s
SDD Gate / Constitution Impact (pull_request) Successful in 21s
CI / Unit & Component Tests (pull_request) Failing after 3m19s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 4m49s
CI / fail2ban Regex (pull_request) Successful in 47s
Architect/Developer review suggestion: flag that other WRITE_ALL-gated author
loads (e.g. documents/[id]/edit) still inline the throw-403 guard and can adopt
requireWriteAll so it doesn't diverge. Comment-only.

Addresses PR #832 review (Architect suggestion).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 00:33:38 +02:00
Marcel
d48a89ba5c test(timeline): assert submit-disabled transition and ≥44px chip targets
Tighten the review-flagged test gaps (no production change):
- submitting state: the old test only asserted the button was initially
  enabled (a tautology). Now stub a never-resolving fetch, click Speichern, and
  assert the button gains `disabled` — exercising the double-submit guard
  (Decision 8).
- the two redirect-throwing save tests now use `await expect(...).rejects` so a
  future missing redirect fails loudly instead of being swallowed by try/catch.
- the YEAR end-date-hide assertion uses getByLabelText('Enddatum') not-present,
  symmetric with the RANGE reveal, instead of a raw #eventDateEnd querySelector.
- PersonMultiSelect + DocumentMultiSelect: assert the chip remove button carries
  the min-h-[44px]/min-w-[44px] target the rtm cites for REQ-017.

Addresses PR #832 review (Tester + Requirements Engineer test-coverage concerns).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 00:33:01 +02:00
Marcel
4dc5e3278f fix(timeline): wire the server date error to the date field's aria-invalid
REQ-011 asks for both required-field errors via per-field aria-invalid, but the
blank-date error was rendered as a standalone <p> after the date card while the
date input's aria-invalid only reflected the client-side malformed-date cue.

DatePrecisionField gains a `dateError` prop: a server error now marks the field
aria-invalid and renders inline under the input (sharing the same error id), and
EventForm drops its detached <p>. While here, migrate the field's two error
texts from hard-coded text-red-600 to the semantic `text-danger` token so they
keep ≥4.5:1 contrast in dark mode (the token remaps; #dc2626 was borderline) —
this also fixes the contrast for WhoWhenSection, the other consumer.

Component test asserts the date input gains aria-invalid on a server date error.

Addresses PR #832 review (Requirements Engineer REQ-011; UI/UX dark-mode contrast).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 00:30:40 +02:00
Marcel
c13baa4785 fix(timeline): rehydrate event-form pickers after a validation failure
Decision 6 / REQ-010 promised the pickers survive a fail(400) "including
pre-selected persons/documents", but EventForm only seeded them from
event/initialPersons — never from the fail payload — and the payload carried
only bare ids, which can't rebuild a chip (chips need displayName/title). On
the use:enhance path the in-memory selection survived; on a no-JS full reload
the chips were silently dropped.

Now the save action re-fetches the selected persons/documents by id
(lookupSelections, non-ok swallowed like the prefill path) and returns full
chip data; EventForm seeds the pickers from form.persons/documents ahead of
the seeded event. Extract preservedFormFields() to DRY the four fail payloads;
validateEventForm now returns the error pair and the route owns the fail().

Component test pins the rehydration; the server spec now asserts the fail
payload carries labelled chips, not just ids.

Addresses PR #832 review (Developer + Requirements Engineer concern, REQ-010).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 00:28:28 +02:00
Marcel
cd5649b96e fix(timeline): harden curator event precision field
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m51s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 4m35s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
SDD Gate / RTM Check (pull_request) Successful in 13s
SDD Gate / Contract Validate (pull_request) Successful in 22s
SDD Gate / Constitution Impact (pull_request) Successful in 17s
- Validate the submitted precision against the DatePrecision allow-list in
  parseEventForm (falls back to DAY) so an untrusted token can't flow into
  the request body — symmetric with the existing `type` narrowing.
- Parameterize the precision input name via DatePrecisionField's new
  precisionInputName prop; the timeline form now submits `precision` instead
  of the misleading document-domain `metaDatePrecision`. Document form keeps
  the default, so its behaviour is unchanged.
- Capture EventTypeSelect's onchange into EventForm's `type` state so it no
  longer goes stale (the submitted value was already correct via the hidden
  input; this keeps the local state in sync).

Addresses PR #832 review (#781).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 23:22:11 +02:00
Marcel
9f17c4538f refactor(timeline): extract requireWriteAll route guard
Both curator-event loaders repeated the same null-user + hasWriteAll block.
hasWriteAll already returns false for an anonymous user, so a single
requireWriteAll(locals) helper covers both REQ-002 (null user → 403) and
REQ-003 (no WRITE_ALL → 403) without the redundant pre-check.

Addresses PR #832 review (#781).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 23:21:37 +02:00
Marcel
068c2ef256 fix(document): render full date for precision-less document chips
A TimelineEvent's DocumentRef carries documentDate but no precision, so
formatDocumentOption hit formatDocumentDate's undefined-precision path and
surfaced the UNKNOWN label instead of the date. Default a missing precision
to DAY so the chip shows the full date; add formatDocumentOption unit specs.

Addresses PR #832 review (#781).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 23:21:07 +02:00
Marcel
94d7d8099f fix(types): make DocumentOption precision fields optional; narrow spec data access
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m0s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 4m54s
CI / fail2ban Regex (pull_request) Successful in 47s
SDD Gate / RTM Check (pull_request) Successful in 15s
SDD Gate / Contract Validate (pull_request) Successful in 22s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
SDD Gate / Constitution Impact (pull_request) Successful in 16s
DocumentOption's metaDatePrecision/metaDateEnd are now optional so a
TimelineEvent DocumentRef (id/title/documentDate only) maps cleanly into a
picker chip — formatDocumentOption already degrades gracefully when precision is
absent. The server specs read fail()'s union data via a small failData() cast
that TS cannot narrow. svelte-check shows zero new errors in the #781 files.

Refs #781
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 23:00:18 +02:00
Marcel
a50bdfa7f4 docs(timeline): document curator event routes (CLAUDE.md, C4, rtm)
Adds the events/new + events/[id]/edit children to both CLAUDE.md route tables
and the frontend C4 people-stories diagram (new zeitstrahlEvents component +
backend relation), and traces REQ-001..017 (feature timeline-curator-forms) in
.specify/rtm.md mirroring the sibling #776/#777/#778/#779 timeline rows.

Refs #781
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:55:58 +02:00
Marcel
be26a2e1b3 test(e2e): thin curator event-editor journey (create, 403, 320px)
One critical create journey (fill form with precision RANGE → HTTP 200 on
/zeitstrahl; the card assertion depends on #7), one security counterpart
(logged-out → 403), and one 320px no-overflow guarantee. Intentionally thin —
ci.yml does not run test:e2e today, so regression coverage lives in the
component + server specs that DO run in CI. Written, not executed locally.

Refs #781
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:53:30 +02:00
Marcel
5cfb4608f6 feat(timeline): add /zeitstrahl/events/[id]/edit curator edit + delete route
Load gates on hasWriteAll (null-user guard first, 403 error page) and seeds the
form from GET /api/timeline/events/{id}, failing closed with 404 on ANY non-ok
response so derived person-events (no UUID) and unknown ids never render a blank
create form. The save action PUTs with the optimistic-lock version (threaded via
a hidden input EventForm now emits), mapping 409 to the generic conflict message
without redirecting. The delete action DELETEs behind getConfirmService(),
returns fail(status) on a non-ok response (no redirect), and otherwise redirects
to the UUID-validated nav target. 8/8 server specs green; EventForm 6/6 green.

Refs #781
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:51:56 +02:00
Marcel
59d78150b3 feat(timeline): add /zeitstrahl/events/new curator create route
Server load gates on hasWriteAll with a null-user guard first (403 error page,
the persons/new idiom — not a redirect); prefills ?personId/?documentId via
Promise.all, swallowing 404/403 so unknown ids never leak. The save action
parses the form, surfaces title+date required errors simultaneously via
fail(400) preserving picker arrays, builds a TimelineEventRequest (eventDateEnd
explicit null off RANGE), POSTs, maps API/409 errors via getErrorMessage without
redirecting, and redirects to a UUID-validated nav target (CWE-601). Shared
parse/validate/build/nav helpers live in eventFormServer.ts for reuse by the
edit route. 11/11 server specs green.

Refs #781
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:49:05 +02:00
Marcel
15ff6db1d3 feat(timeline): add EventForm curator create/edit form
One component for both routes: /new renders it empty, /[id]/edit seeds it from a
TimelineEventView. Composes EventTypeSelect, the shared DatePrecisionField, a
plain-textarea description, PersonMultiSelect and DocumentMultiSelect (personIds
/documentIds hidden inputs). lg:grid-cols-[2fr_1fr] collapsing to one column
below lg, sticky save bar, beforeNavigate unsaved-changes guard, submitting flag
via use:enhance (disabled submit), and a delete form gated by getConfirmService()
read lazily so the component mounts cleanly in isolation. Title/description/chip
labels render via default {...} escaping (CWE-79). Seeded DocumentRefs degrade
gracefully to DocumentOption (no precision fields). Pickers gain an inputId prop
so <label for> associates the control; eslint boundaries now lets timeline import
person+document (mirrors the geschichte editor). 6/6 component specs green.

Refs #781
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:45:39 +02:00
Marcel
54f9d8fdd5 feat(timeline): add EventTypeSelect segmented radio (PERSONAL/HISTORICAL)
grid-cols-2 segmented radio group modelled on PersonTypeSelector: role=radiogroup
with role=radio buttons, roving tabindex, radioGroupNav arrow-key support, and an
sr-only aria-live type-change announcement. Each option pairs a decorative
aria-hidden icon with a visible localized text label (icon is never the sole
differentiator), min-h-48px target. Emits a hidden input for form submission.

Refs #781
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:40:00 +02:00
Marcel
423aedcd87 fix(a11y): 44px remove targets + empty states on person/document pickers
Both PersonMultiSelect and DocumentMultiSelect remove buttons were ~12px tap
targets (below the 44px WCAG minimum) — pad them to min-h/min-w 44px with a
focus-visible ring (SVG stays 12px). Add an optional emptyLabel slot inside the
chip container and a hiddenInputName prop on PersonMultiSelect (mirroring
DocumentMultiSelect) so EventForm can wire personIds without touching
WhoWhenSection. Document the intentional bare typeahead fetch in
documentTypeahead.ts (same-origin in prod, Vite-proxied in dev).

Refs #781
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:38:00 +02:00
Marcel
0ed7fb4c0e i18n(timeline): add event-editor keys (de/en/es)
Labels, section headings, type options (PERSONAL/HISTORICAL), picker empty
states, required-field errors, delete-confirm and unsaved-changes copy for the
curator event create/edit forms. No new ErrorCode introduced — the feature
reuses existing TIMELINE_EVENT_* + CONFLICT codes from #3.

Refs #781
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:35:57 +02:00
Marcel
62fcc53f5c refactor(shared): extract DatePrecisionField primitive from WhoWhenSection
Pulls the date + precision + RANGE-end-date region into a generic primitive in
$lib/shared/primitives/ so both document/ (WhoWhenSection) and timeline/
(EventForm, #781) can consume it without a cross-domain import. Preserves the
aria-live="polite" outer wrapper, onMount one-time seeding, $bindable
precision/endDateIso, the PRECISIONS array, and forwards data-testid attributes
so the existing WhoWhenSection spec selectors survive. WhoWhenSection spec stays
green (7/7).

Refs #781
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:33:50 +02:00
Marcel
36f7bdad45 test(document): fence RANGE end-date reveal before DatePrecisionField extraction
Adds a RANGE-reveal regression test to WhoWhenSection's spec. The existing
spec covered only date pre-fill / hideDate / location, leaving the end-date
region without a red signal. This must stay green across the #781 extraction
of DatePrecisionField into $lib/shared/primitives/.

Refs #781
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:30:56 +02:00
Marcel
696a86799d fix(a11y): add aria-modal to ConfirmDialog for older AT
NVDA+Chrome <2022 and VoiceOver iOS <16 need explicit aria-modal="true";
showModal() implicit modal semantics are not enough for older AT. One-line
patch benefits all dialog uses.

Refs #781
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:29:46 +02:00
33 changed files with 2045 additions and 158 deletions

View File

@@ -106,3 +106,20 @@
| REQ-025 | personId prop declared but undefined in global view; not passed to leaf cards | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#renders all years and undated entries with personId undefined` | Done |
| REQ-026 | month-bucket helpers in $lib/shared/utils/monthBuckets.ts; no lib/timeline → lib/document import | #779 | zeitstrahl-global-view | `frontend/src/lib/shared/utils/monthBuckets.ts` | `monthBuckets.spec.ts` (relocated) + eslint boundary + `grep lib/document` → zero | Done |
| REQ-027 | monthHistogram returns 12 MonthBuckets for the band year via shared fillDensityGaps | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/timelineDensity.ts` | `timelineDensity.spec.ts#monthHistogram` | Done |
| REQ-001 | curator with WRITE_ALL granted access to /zeitstrahl/events/new + /[id]/edit | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts`, `[id]/edit/+page.server.ts` | `new/page.server.spec.ts#allows a curator with WRITE_ALL`, `[id]/edit/page.server.spec.ts#seeds the form with the event on an ok GET` | Done |
| REQ-002 | unauthenticated (null user) → 403 (null-user guard before groups deref) | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts`, `[id]/edit/+page.server.ts` | `new/page.server.spec.ts#throws 403 for an unauthenticated (null) user`, `[id]/edit/page.server.spec.ts#throws 403 for an unauthenticated (null) user` | Done |
| REQ-003 | authenticated without WRITE_ALL → 403 | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts` (hasWriteAll) | `new/page.server.spec.ts#throws 403 for an authenticated user without WRITE_ALL`, `[id]/edit/page.server.spec.ts#throws 403 for a user without WRITE_ALL` | Done |
| REQ-004 | valid create → POST + redirect to resolved target | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts` (save), `lib/timeline/eventFormServer.ts#toEventRequest` | `new/page.server.spec.ts#posts a TimelineEventRequest and redirects on success` | Done |
| REQ-005 | valid edit → PUT + redirect to resolved target | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (save) | `[id]/edit/page.server.spec.ts#updates via PUT (with version) and redirects on success` | Done |
| REQ-006 | confirmed delete → DELETE + redirect | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (delete), `lib/timeline/EventForm.svelte` (getConfirmService) | `[id]/edit/page.server.spec.ts#deletes via DELETE and redirects to the resolved target on success` | Done |
| REQ-007 | non-ok DELETE → surface mapped error, no redirect | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (delete) | `[id]/edit/page.server.spec.ts#returns fail(status) and does not redirect when DELETE is not ok` | Done |
| REQ-008 | precision = RANGE → end-date field visible | #781 | timeline-curator-forms | `frontend/src/lib/shared/primitives/DatePrecisionField.svelte`, `lib/timeline/EventForm.svelte` | `EventForm.svelte.spec.ts#reveals the end-date field when precision is RANGE`, `WhoWhenSection.svelte.spec.ts#reveals the end-date field when precision is RANGE` | Done |
| REQ-009 | precision ≠ RANGE → end-date hidden, eventDateEnd submitted null | #781 | timeline-curator-forms | `frontend/src/lib/shared/primitives/DatePrecisionField.svelte`, `lib/timeline/eventFormServer.ts#parseEventForm` | `EventForm.svelte.spec.ts#hides the end-date field when precision is YEAR`, `new/page.server.spec.ts#sends eventDateEnd: null when precision is not RANGE` | Done |
| REQ-010 | blank title → localized required error, no nav, picker values preserved | #781 | timeline-curator-forms | `frontend/src/lib/timeline/eventFormServer.ts#validateEventForm`, `EventForm.svelte` | `EventForm.svelte.spec.ts#shows a required-field error when title is blank`, `new/page.server.spec.ts#returns fail(400) with preserved picker arrays on blank title` | Done |
| REQ-011 | blank title + date → both errors via per-field aria-invalid | #781 | timeline-curator-forms | `frontend/src/lib/timeline/eventFormServer.ts#validateEventForm` | `new/page.server.spec.ts#surfaces both title and date errors when both blank` | Done |
| REQ-012 | unknown/derived event id (non-ok GET) → 404, never blank create form | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (load) | `[id]/edit/page.server.spec.ts#throws 404 when the GET is not ok (unknown or derived id)` | Done |
| REQ-013 | 409 Conflict → generic conflict message, no redirect (no merge UI) | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (save) | `[id]/edit/page.server.spec.ts#maps a 409 conflict and does not redirect`, `new/page.server.spec.ts#maps the API error and does not redirect on a non-ok save (incl. 409)` | Done |
| REQ-014 | valid ?personId/?documentId prefill pre-selected; unknown id silently ignored | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts` (load Promise.all), `EventForm.svelte` | `new/page.server.spec.ts#preselects a valid person and ignores an unknown document`, `EventForm.svelte.spec.ts#preselects a person when initialPersons is provided` | Done |
| REQ-015 | absent/empty/non-UUID originPersonId → redirect /zeitstrahl (CWE-601) | #781 | timeline-curator-forms | `frontend/src/lib/timeline/eventFormServer.ts#resolveNavTarget` | `new/page.server.spec.ts#defaults to /zeitstrahl when originPersonId is not a valid UUID`, `#redirects to /persons/{id} when originPersonId is a valid UUID` | Done |
| REQ-016 | title/description/chip labels via default `{...}` escaping, never `{@html}` (CWE-79) | #781 | timeline-curator-forms | `frontend/src/lib/timeline/EventForm.svelte` | code review + `grep -r '@html' frontend/src/lib/timeline/` → zero | Done |
| REQ-017 | labelled pickers, visible empty states, ≥44px chip remove targets | #781 | timeline-curator-forms | `frontend/src/lib/person/PersonMultiSelect.svelte`, `document/DocumentMultiSelect.svelte`, `EventForm.svelte` | `PersonMultiSelect.svelte.spec.ts`, `DocumentMultiSelect.svelte.spec.ts` (green post-44px fix), `EventForm.svelte.spec.ts#preselects a person when initialPersons is provided` | Done |

View File

@@ -208,6 +208,7 @@ frontend/src/routes/
├── geschichten/ Stories — list, [id], [id]/edit, new
├── stammbaum/ Family tree (Stammbaum)
├── zeitstrahl/ Global timeline (Zeitstrahl) — life-events + events + letters woven in time; SSR-loads GET /api/timeline, renders lib/timeline/TimelineView (Datum mode)
│ └── events/ Curator event editor (WRITE_ALL-gated) — new (create) + [id]/edit (edit + delete); reuses lib/timeline/EventForm
├── themen/ Topics directory — browsable tag index
├── enrich/ Enrichment workflow — [id], done
├── admin/ User, group, tag, OCR, system management

View File

@@ -15,6 +15,7 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor and creation flow. New: TypeSelector (STORY/JOURNEY radio group with roving tabindex) → StoryCreate (TipTap rich text, person linking, POST /api/geschichten) or JourneyCreate (title + first item). Edit: branches on GeschichteType — STORY opens GeschichteEditor (TipTap body + GeschichteSidebar incl. StoryDocumentPanel: document linking via POST/DELETE /items); JOURNEY opens JourneyEditor (title, intro textarea, ordered JourneyItemRow list with drag-reorder + move-up/down, JourneyAddBar for document/interlude addition, GeschichteSidebar). JourneyEditor mutations: POST/DELETE /items, PUT /items/reorder, PATCH /items/{id}. Requires BLOG_WRITE permission.")
Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.")
Component(zeitstrahl, "/zeitstrahl", "SvelteKit Route", "Global timeline (Zeitstrahl). SSR loader: GET /api/timeline -> TimelineDTO. Renders lib/timeline/TimelineView (Datum mode): year bands (YearBand) with EventPill / WorldBand / LetterCard, dense-year YearLetterStrip (shared Sparkline + monthHistogram), folded GapSpan for empty-year runs, and an undated bucket. personId prop is the per-person Lebensweg seam (issue #10), undefined here.")
Component(zeitstrahlEvents, "/zeitstrahl/events/new and /zeitstrahl/events/[id]/edit", "SvelteKit Routes", "Curator event editor (WRITE_ALL-gated via server load, 403 error page). One lib/timeline/EventForm for both routes: title, EventTypeSelect (PERSONAL/HISTORICAL segmented radio), shared DatePrecisionField (RANGE reveals end date), plain-text description, PersonMultiSelect + DocumentMultiSelect. New: ?personId/?documentId prefill via Promise.all (404/403 swallowed), POST /api/timeline/events. Edit: load seeds from GET /api/timeline/events/{id} (404 on any non-ok — fails closed against derived events), PUT (optimistic-lock version) + DELETE behind ConfirmDialog. Context-aware redirect via UUID-validated originPersonId.")
Component(themen, "/themen", "SvelteKit Route", "Browsable topic index. Shows all root tags as cards with color bars and child rows. ThemenWidget also embedded in the home dashboard (reader + editor sidebar). Loader: GET /api/tags/tree.")
Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.")
Component(userProfile, "/users/[id]", "SvelteKit Route", "Public user profile view. Loader: GET /api/users/{id}.")
@@ -30,6 +31,7 @@ Rel(geschichtenEdit, backend, "GET /api/persons/{id} (pre-populate), POST /api/g
Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON")
Rel(user, zeitstrahl, "Reads the family timeline", "HTTPS / Browser")
Rel(zeitstrahl, backend, "GET /api/timeline -> TimelineDTO", "HTTP / JSON")
Rel(zeitstrahlEvents, backend, "GET /api/timeline/events/{id}, POST /api/timeline/events, PUT/DELETE /api/timeline/events/{id}, GET /api/persons/{id} + /api/documents/{id} (prefill)", "HTTP / JSON")
Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON")
Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")
Rel(userProfile, backend, "GET /api/users/{id}", "HTTP / JSON")

View File

@@ -34,7 +34,7 @@ src/
│ ├── api/ # Internal API proxies (server-side only)
│ ├── geschichten/ # Stories (list, [id], [id]/edit, new)
│ ├── stammbaum/ # Family tree
│ ├── zeitstrahl/ # Global timeline (Zeitstrahl) — SSR loads /api/timeline, renders lib/timeline
│ ├── zeitstrahl/ # Global timeline (Zeitstrahl) — SSR loads /api/timeline, renders lib/timeline; events/new + events/[id]/edit curator editor (WRITE_ALL-gated)
│ ├── enrich/ # Enrichment workflow ([id], done)
│ ├── hilfe/transkription/ # Transcription help page
│ ├── profile/ # User profile settings

View File

@@ -26,7 +26,7 @@ test.describe('Document auto-title sync (#726)', () => {
// 3. Add a YEAR-precision date WITHOUT touching the title, then save.
await page.locator('#documentDate').fill('15.01.1928');
await page.locator('#metaDatePrecision').selectOption('YEAR');
await page.locator('#documentDatePrecision').selectOption('YEAR');
await page.getByRole('button', { name: 'Speichern', exact: true }).click();
// 4. The detail page shows the regenerated title carrying the new year.

View File

@@ -0,0 +1,65 @@
import { test, expect } from '@playwright/test';
/**
* Curator timeline event editor (#781) — intentionally thin. The component +
* server specs carry the real regression coverage (they run in CI's "Unit &
* Component Tests" job); ci.yml does NOT invoke test:e2e today, so this file
* runs only locally/manually against the full Docker Compose stack.
*
* Three checks: one critical create journey (→ HTTP 200 on /zeitstrahl; the full
* "sees the event card" assertion depends on #7), one security counterpart
* (logged-out → 403), and one 320px no-overflow guarantee for the 60+ author
* audience.
*/
const stamp = () => new Date().toISOString().replace(/[^0-9]/g, '');
test.describe('Curator creates a timeline event', () => {
test('fills the create form with precision RANGE and lands on /zeitstrahl (HTTP 200)', async ({
page
}) => {
await page.goto('/zeitstrahl/events/new');
await page.getByLabel(/Titel/i).fill(`E2E Ereignis ${stamp()}`);
await page.getByRole('radio', { name: /Historisch/i }).click();
// Date + RANGE end date via the shared German dd.mm.yyyy inputs.
await page.locator('#eventDate').fill('01.04.1925');
await page.locator('#eventDatePrecision').selectOption('RANGE');
await expect(page.getByLabel('Enddatum')).toBeVisible();
await page.locator('#eventDateEnd').fill('01.05.1925');
// Submitting redirects to the resolved nav target (/zeitstrahl) — assert the
// route responds 200, not a DOM card (card rendering is #7's concern).
await Promise.all([
page.waitForURL(/\/zeitstrahl$/),
page.getByRole('button', { name: 'Speichern' }).click()
]);
const response = await page.goto('/zeitstrahl');
expect(response?.status()).toBe(200);
});
});
test.describe('Logged-out user is blocked from the curator route', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('navigating to /zeitstrahl/events/new is blocked with 403', async ({ page }) => {
await page.goto('/zeitstrahl/events/new');
// The load guard throws 403 before any form renders.
await expect(page.getByLabel(/Titel/i)).not.toBeVisible({ timeout: 5000 });
await expect(page.getByText(/403|Zugriff verweigert|Forbidden/i)).toBeVisible({
timeout: 5000
});
});
});
test.describe('Responsive — 60+ author audience', () => {
test('no horizontal overflow on the create form at 320px', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 900 });
await page.goto('/zeitstrahl/events/new');
await expect(page.getByLabel(/Titel/i)).toBeVisible();
const scrollWidth = await page.evaluate(() => document.body.scrollWidth);
expect(scrollWidth).toBe(320);
});
});

View File

@@ -199,7 +199,12 @@ export default defineConfig(
{ from: { type: 'user' }, allow: { to: { type: ['shared'] } } },
{ from: { type: 'notification' }, allow: { to: { type: ['shared'] } } },
{ from: { type: 'conversation' }, allow: { to: { type: ['shared'] } } },
{ from: { type: 'timeline' }, allow: { to: { type: ['shared'] } } },
// Timeline curator event editor selects persons and documents by
// design (mirrors the geschichte editor) — #781.
{
from: { type: 'timeline' },
allow: { to: { type: ['shared', 'person', 'document'] } }
},
{ from: { type: 'shared' }, allow: { to: { type: ['shared'] } } },
{
from: { type: 'routes' },

View File

@@ -1046,6 +1046,32 @@
"timeline_derived_birth": "Geburt",
"timeline_derived_death": "Tod",
"timeline_derived_marriage": "Heirat",
"event_editor_new_title": "Neues Ereignis",
"event_editor_edit_title": "Ereignis bearbeiten",
"event_editor_section_when": "Wann",
"event_editor_section_persons": "Beteiligte Personen",
"event_editor_section_documents": "Verknüpfte Briefe",
"event_editor_section_description": "Beschreibung",
"event_editor_title_label": "Titel",
"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",
"event_editor_description_label": "Beschreibung",
"event_editor_description_placeholder": "Optionale Beschreibung",
"event_editor_persons_empty": "Noch keine Person verknüpft",
"event_editor_documents_empty": "Noch kein Dokument verknüpft",
"event_type_PERSONAL": "Persönlich",
"event_type_HISTORICAL": "Historisch",
"event_editor_save": "Speichern",
"event_editor_save_hint": "Ereignisse erscheinen im Zeitstrahl.",
"event_editor_delete": "Löschen",
"event_editor_delete_confirm_title": "Ereignis löschen?",
"event_editor_delete_confirm_body": "Dieses Ereignis wird dauerhaft entfernt.",
"event_editor_unsaved_changes": "Du hast ungespeicherte Änderungen — wirklich verlassen?",
"error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.",
"error_journey_item_not_found": "Der Reise-Eintrag wurde nicht gefunden.",
"error_journey_item_position_conflict": "Die Reihenfolge wurde gerade von jemand anderem geändert bitte laden Sie die Seite neu.",

View File

@@ -1046,6 +1046,32 @@
"timeline_derived_birth": "Birth",
"timeline_derived_death": "Death",
"timeline_derived_marriage": "Marriage",
"event_editor_new_title": "New event",
"event_editor_edit_title": "Edit event",
"event_editor_section_when": "When",
"event_editor_section_persons": "People involved",
"event_editor_section_documents": "Linked letters",
"event_editor_section_description": "Description",
"event_editor_title_label": "Title",
"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",
"event_editor_description_label": "Description",
"event_editor_description_placeholder": "Optional description",
"event_editor_persons_empty": "No person linked yet",
"event_editor_documents_empty": "No document linked yet",
"event_type_PERSONAL": "Personal",
"event_type_HISTORICAL": "Historical",
"event_editor_save": "Save",
"event_editor_save_hint": "Events appear on the timeline.",
"event_editor_delete": "Delete",
"event_editor_delete_confirm_title": "Delete event?",
"event_editor_delete_confirm_body": "This event will be permanently removed.",
"event_editor_unsaved_changes": "You have unsaved changes — really leave?",
"error_geschichte_not_found": "The story was not found.",
"error_journey_item_not_found": "The journey item was not found.",
"error_journey_item_position_conflict": "The order was just changed by someone else — please reload the page.",

View File

@@ -1046,6 +1046,32 @@
"timeline_derived_birth": "Nacimiento",
"timeline_derived_death": "Fallecimiento",
"timeline_derived_marriage": "Matrimonio",
"event_editor_new_title": "Nuevo evento",
"event_editor_edit_title": "Editar evento",
"event_editor_section_when": "Cuándo",
"event_editor_section_persons": "Personas involucradas",
"event_editor_section_documents": "Cartas vinculadas",
"event_editor_section_description": "Descripción",
"event_editor_title_label": "Título",
"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",
"event_editor_description_label": "Descripción",
"event_editor_description_placeholder": "Descripción opcional",
"event_editor_persons_empty": "Aún no hay ninguna persona vinculada",
"event_editor_documents_empty": "Aún no hay ningún documento vinculado",
"event_type_PERSONAL": "Personal",
"event_type_HISTORICAL": "Histórico",
"event_editor_save": "Guardar",
"event_editor_save_hint": "Los eventos aparecen en la cronología.",
"event_editor_delete": "Eliminar",
"event_editor_delete_confirm_title": "¿Eliminar evento?",
"event_editor_delete_confirm_body": "Este evento se eliminará de forma permanente.",
"event_editor_unsaved_changes": "Tienes cambios sin guardar — ¿salir de todos modos?",
"error_geschichte_not_found": "No se encontró la historia.",
"error_journey_item_not_found": "No se encontró el elemento del viaje.",
"error_journey_item_position_conflict": "El orden fue cambiado por otra persona — por favor recargue la página.",

View File

@@ -11,12 +11,21 @@ interface Props {
selectedDocuments?: DocumentOption[];
placeholder?: string;
hiddenInputName?: string;
/** Empty-state text shown inside the chip container when nothing is selected. */
emptyLabel?: string;
/** id of the search input so a <label for=...> can be associated. */
inputId?: string;
/** Called when the selection changes (add/remove) — lets a parent track dirtiness. */
onchange?: () => void;
}
let {
selectedDocuments = $bindable([]),
placeholder = m.geschichte_editor_search_document(),
hiddenInputName = 'documentIds'
hiddenInputName = 'documentIds',
emptyLabel = undefined,
inputId = undefined,
onchange = undefined
}: Props = $props();
let searchTerm = $state('');
@@ -48,10 +57,12 @@ function selectDocument(doc: DocumentOption) {
selectedDocuments = [...selectedDocuments, doc];
searchTerm = '';
picker.close();
onchange?.();
}
function removeDocument(id: string | undefined) {
selectedDocuments = selectedDocuments.filter((d) => d.id !== id);
onchange?.();
}
</script>
@@ -73,7 +84,7 @@ function removeDocument(id: string | undefined) {
<button
type="button"
onclick={() => removeDocument(doc.id)}
class="ml-0.5 text-ink/50 hover:text-red-500 focus:outline-none"
class="ml-0.5 inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded-sm text-ink/50 hover:text-red-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
aria-label={m.comp_multiselect_remove()}
>
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -88,8 +99,13 @@ function removeDocument(id: string | undefined) {
</span>
{/each}
{#if emptyLabel && selectedDocuments.length === 0}
<span class="px-1 py-1 font-sans text-sm text-ink-3 italic">{emptyLabel}</span>
{/if}
<input
bind:this={inputEl}
id={inputId}
type="text"
autocomplete="off"
bind:value={searchTerm}

View File

@@ -157,4 +157,14 @@ describe('DocumentMultiSelect — remove', () => {
document.querySelector<HTMLInputElement>('input[type="hidden"][name="documentIds"]')
).toBeNull();
});
// REQ-017 (#781): chip remove targets must be ≥44px for the 60+ audience.
it('renders a ≥44px touch target on the chip remove button', async () => {
render(DocumentMultiSelect, {
selectedDocuments: [docFactory('d1', 'Brief A')]
});
const removeBtn = (await page.getByLabelText('Entfernen').element()) as HTMLElement;
expect(removeBtn.className).toContain('min-h-[44px]');
expect(removeBtn.className).toContain('min-w-[44px]');
});
});

View File

@@ -1,9 +1,8 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
import PersonMultiSelect from '$lib/person/PersonMultiSelect.svelte';
import FieldLabelBadge from '$lib/shared/primitives/FieldLabelBadge.svelte';
import { isoToGerman, handleGermanDateInput } from '$lib/shared/utils/date';
import DatePrecisionField from '$lib/shared/primitives/DatePrecisionField.svelte';
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
@@ -37,64 +36,6 @@ let {
hideDate?: boolean;
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;
if (seed) {
dateDisplay = isoToGerman(seed);
if (!dateIso) dateIso = seed;
}
if (endDateIso) endDisplay = isoToGerman(endDateIso);
});
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;
dateIso = result.iso;
dateDirty = true;
}
function handleEndDateInput(e: Event) {
const result = handleGermanDateInput(e);
endDisplay = result.display;
endDateIso = result.iso;
}
$effect(() => {
const suggested = suggestedDateIso;
if (suggested && !untrack(() => dateDirty)) {
dateDisplay = isoToGerman(suggested);
dateIso = suggested;
}
});
</script>
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
@@ -104,79 +45,22 @@ $effect(() => {
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
{#if !hideDate}
<!-- Datum (required — row 1, col 1) -->
<div data-testid="who-when-date">
<label for="documentDate" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_date()}*</label
>
<input
id="documentDate"
type="text"
inputmode="numeric"
value={dateDisplay}
oninput={handleDateInput}
placeholder={m.form_placeholder_date()}
maxlength="10"
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm
{dateInvalid
? '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'}"
aria-describedby={dateInvalid ? 'date-error' : undefined}
/>
<input type="hidden" name="documentDate" value={dateIso} />
{#if dateInvalid}
<p id="date-error" class="mt-1 text-xs text-red-600">{m.form_date_error()}</p>
{/if}
</div>
<!-- Datumsgenauigkeit (precision) -->
<div data-testid="who-when-precision">
<label for="metaDatePrecision" class="mb-1 block text-sm font-medium text-ink-2">
{m.form_label_date_precision()}
</label>
<select
id="metaDatePrecision"
name="metaDatePrecision"
bind:value={precision}
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"
>
{#each PRECISIONS as p (p.value)}
<option value={p.value}>{p.label()}</option>
{/each}
</select>
</div>
<!-- Enddatum: progressive disclosure, revealed only for RANGE, announced politely. -->
<div aria-live="polite">
{#if showEndDate}
<div data-testid="who-when-end-date">
<label for="metaDateEnd" class="mb-1 block text-sm font-medium text-ink-2">
{m.form_label_date_end()}
</label>
<input
id="metaDateEnd"
type="text"
inputmode="numeric"
value={endDisplay}
oninput={handleEndDateInput}
placeholder={m.form_placeholder_date()}
maxlength="10"
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}
<!-- Non-colour cue (WCAG 1.4.1): warning glyph + text, not red alone. -->
<p id="end-date-error" class="mt-1 text-xs text-red-600">
<span aria-hidden="true"></span>{m.error_invalid_date_range()}
</p>
{/if}
</div>
{/if}
</div>
<input type="hidden" name="metaDateEnd" value={showEndDate ? endDateIso : ''} />
<!-- Datum + Präzision + Enddatum (shared primitive, #781). The three grid
cells slot directly into this grid; testids are forwarded so the
existing WhoWhenSection selectors survive the extraction. -->
<DatePrecisionField
bind:dateIso={dateIso}
bind:precision={precision}
bind:endDateIso={endDateIso}
initialDateIso={initialDateIso}
suggestedDateIso={suggestedDateIso}
dateInputName="documentDate"
endDateInputName="metaDateEnd"
dateLabel={m.form_label_date()}
dateTestId="who-when-date"
precisionTestId="who-when-precision"
endDateInnerTestId="who-when-end-date"
/>
{/if}
<!-- Absender (required in upload mode — row 1, col 2) -->

View File

@@ -39,4 +39,17 @@ describe('WhoWhenSection — onMount seeding (Felix B1 fix regression fence)', (
const locationInput = document.querySelector('input#location') as HTMLInputElement;
expect(locationInput.value).toBe('Berlin');
});
// Regression fence for the DatePrecisionField extraction (#781): the existing
// spec covered only date pre-fill / hideDate / location, so the RANGE end-date
// reveal had no red signal. This test must stay green across the extraction.
it('reveals the end-date field when precision is RANGE', async () => {
render(WhoWhenSection, { precision: 'RANGE' });
await expect.element(page.getByLabelText('Enddatum')).toBeVisible();
});
it('hides the end-date field when precision is not RANGE', async () => {
render(WhoWhenSection, { precision: 'YEAR' });
await expect.element(page.getByTestId('who-when-end-date')).not.toBeInTheDocument();
});
});

View File

@@ -15,14 +15,14 @@ describe('WhoWhenSection — date input behavior', () => {
await vi.waitFor(() => {
// Invalid → border-red-400 class
expect(dateInput.className).toContain('border-red-400');
expect(document.querySelector('#date-error')).not.toBeNull();
expect(document.querySelector('#documentDate-error')).not.toBeNull();
});
});
it('does not show the error before the user has typed', async () => {
render(WhoWhenSection, {});
const error = document.querySelector('#date-error');
const error = document.querySelector('#documentDate-error');
expect(error).toBeNull();
});
@@ -77,20 +77,20 @@ 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"]');
const label = document.querySelector('label[for="documentDatePrecision"]');
const select = document.querySelector('select#documentDatePrecision[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();
expect(document.querySelector('input#documentDateEnd')).toBeNull();
});
it('reveals the end-date field when precision is RANGE', async () => {
render(WhoWhenSection, { precision: 'RANGE' });
expect(document.querySelector('input#metaDateEnd')).not.toBeNull();
expect(document.querySelector('input#documentDateEnd')).not.toBeNull();
});
it('never renders the raw cell, and never re-submits it via a hidden input', async () => {
@@ -110,9 +110,9 @@ describe('WhoWhenSection — end-before-start inline validation (#678)', () => {
endDateIso: '1917-01-10'
});
const end = document.querySelector('input#metaDateEnd') as HTMLInputElement;
const end = document.querySelector('input#documentDateEnd') as HTMLInputElement;
await vi.waitFor(() => {
expect(document.querySelector('#end-date-error')).not.toBeNull();
expect(document.querySelector('#documentDate-end-error')).not.toBeNull();
expect(end.getAttribute('aria-invalid')).toBe('true');
expect(end.className).toContain('border-red-400');
});
@@ -125,14 +125,16 @@ describe('WhoWhenSection — end-before-start inline validation (#678)', () => {
endDateIso: '1917-01-10'
});
await vi.waitFor(() => expect(document.querySelector('#end-date-error')).not.toBeNull());
await vi.waitFor(() =>
expect(document.querySelector('#documentDate-end-error')).not.toBeNull()
);
const end = document.querySelector('input#metaDateEnd') as HTMLInputElement;
const end = document.querySelector('input#documentDateEnd') 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(document.querySelector('#documentDate-end-error')).toBeNull();
expect(end.getAttribute('aria-invalid')).not.toBe('true');
});
});
@@ -144,6 +146,6 @@ describe('WhoWhenSection — end-before-start inline validation (#678)', () => {
endDateIso: '1917-01-10'
});
expect(document.querySelector('#end-date-error')).toBeNull();
expect(document.querySelector('#documentDate-end-error')).toBeNull();
});
});

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { formatDocumentOption, type DocumentOption } from './documentTypeahead';
describe('formatDocumentOption', () => {
it('returns the bare title when no documentDate is present', () => {
const doc: DocumentOption = { id: 'd1', title: 'Brief ohne Datum' };
expect(formatDocumentOption(doc)).toBe('Brief ohne Datum');
});
// #781: a TimelineEvent's DocumentRef carries documentDate but no precision.
// Missing precision must degrade to the full date (DAY), never the UNKNOWN label.
it('renders the full date when precision is absent (DocumentRef chip)', () => {
const doc: DocumentOption = { id: 'd1', title: 'Umzugsbrief', documentDate: '1925-04-01' };
const label = formatDocumentOption(doc);
expect(label.startsWith('Umzugsbrief · ')).toBe(true);
expect(label).toContain('1925');
// The undefined-precision fallback would otherwise surface the UNKNOWN word.
expect(label.toLowerCase()).not.toContain('unbekannt');
});
});

View File

@@ -5,13 +5,21 @@ import { getLocale } from '$lib/paraglide/runtime.js';
type DocumentListItem = components['schemas']['DocumentListItem'];
export type DocumentOption = Pick<
DocumentListItem,
'id' | 'title' | 'documentDate' | 'metaDatePrecision' | 'metaDateEnd'
>;
/**
* Chip/dedup contract for document pickers. `metaDatePrecision`/`metaDateEnd`
* are optional: the typeahead always populates them, but a TimelineEvent's
* DocumentRef (#781) carries only id/title/documentDate — formatDocumentOption
* degrades gracefully (bare title or plain date) when precision is absent.
*/
export type DocumentOption = Pick<DocumentListItem, 'id' | 'title' | 'documentDate'> &
Partial<Pick<DocumentListItem, 'metaDatePrecision' | 'metaDateEnd'>>;
export function createDocumentTypeahead() {
return createTypeahead<DocumentOption>({
// Intentional bare browser fetch (matches the Geschichte editor): in dev the
// Vite proxy forwards /api and injects the auth header; in prod the app is
// same-origin so the auth cookie travels automatically. An internal
// +server.ts proxy would add complexity with no practical security benefit.
fetchUrl: (q) =>
fetch(`/api/documents/search?q=${encodeURIComponent(q)}&size=10`)
.then((r) => {
@@ -34,9 +42,12 @@ export function createDocumentTypeahead() {
export function formatDocumentOption(doc: DocumentOption): string {
if (!doc.documentDate) return doc.title;
// A DocumentRef (#781 timeline chips) carries documentDate but no precision —
// default to DAY so the full date renders, rather than the UNKNOWN fallback
// formatDocumentDate would otherwise hit for an undefined precision.
const label = formatDocumentDate(
doc.documentDate,
doc.metaDatePrecision as DatePrecision,
(doc.metaDatePrecision as DatePrecision) ?? 'DAY',
doc.metaDateEnd,
null,
getLocale()

View File

@@ -7,9 +7,23 @@ type Person = components['schemas']['Person'];
interface Props {
selectedPersons?: PersonOption[];
/** Name of the hidden inputs carrying selected ids. Mirrors DocumentMultiSelect. */
hiddenInputName?: string;
/** Empty-state text shown inside the chip container when nothing is selected. */
emptyLabel?: string;
/** id of the search input so a <label for=...> can be associated. */
inputId?: string;
/** Called when the selection changes (add/remove) — lets a parent track dirtiness. */
onchange?: () => void;
}
let { selectedPersons = $bindable([]) }: Props = $props();
let {
selectedPersons = $bindable([]),
hiddenInputName = 'receiverIds',
emptyLabel = undefined,
inputId = undefined,
onchange = undefined
}: Props = $props();
let searchTerm = $state('');
let results: Person[] = $state([]);
@@ -54,17 +68,19 @@ function selectPerson(person: Person) {
searchTerm = '';
showDropdown = false;
results = [];
onchange?.();
}
function removePerson(id: string | undefined) {
selectedPersons = selectedPersons.filter((p) => p.id !== id);
onchange?.();
}
</script>
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
{#each selectedPersons as person (person.id)}
<input type="hidden" name="receiverIds" value={person.id} />
<input type="hidden" name={hiddenInputName} value={person.id} />
{/each}
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
@@ -79,7 +95,7 @@ function removePerson(id: string | undefined) {
<button
type="button"
onclick={() => removePerson(person.id)}
class="ml-0.5 text-ink/50 hover:text-red-500 focus:outline-none"
class="ml-0.5 inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded-sm text-ink/50 hover:text-red-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
aria-label={m.comp_multiselect_remove()}
>
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -94,8 +110,13 @@ function removePerson(id: string | undefined) {
</span>
{/each}
{#if emptyLabel && selectedPersons.length === 0}
<span class="px-1 py-1 font-sans text-sm text-ink-3 italic">{emptyLabel}</span>
{/if}
<input
bind:this={inputEl}
id={inputId}
type="text"
autocomplete="off"
bind:value={searchTerm}

View File

@@ -258,6 +258,19 @@ describe('PersonMultiSelect removing persons', () => {
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
});
// REQ-017 (#781): chip remove targets must be ≥44px for the 60+ audience.
it('renders a ≥44px touch target on the chip remove button', async () => {
render(PersonMultiSelect, {
selectedPersons: [{ id: '1', displayName: 'Max Mustermann' }]
});
const removeBtn = (await page
.getByRole('button', { name: 'Entfernen' })
.first()
.element()) as HTMLElement;
expect(removeBtn.className).toContain('min-h-[44px]');
expect(removeBtn.className).toContain('min-w-[44px]');
});
it('removes the corresponding hidden input when a chip is removed', async () => {
render(PersonMultiSelect, {
selectedPersons: [

View File

@@ -19,6 +19,7 @@ $effect(() => {
<dialog
bind:this={dialogEl}
class="m-auto w-full max-w-sm rounded-sm border border-line bg-surface p-6 shadow-lg backdrop:bg-black/50"
aria-modal="true"
aria-labelledby="confirm-title"
oncancel={(e) => {
e.preventDefault();

View File

@@ -0,0 +1,221 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { isoToGerman, handleGermanDateInput } from '$lib/shared/utils/date';
import { m } from '$lib/paraglide/messages.js';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
/**
* Generic date + precision input primitive shared by two domains:
* `document/` (via WhoWhenSection) and `timeline/` (via EventForm).
*
* Renders three grid cells — a German `dd.mm.yyyy` text input backed by a hidden
* ISO input, a precision <select>, and a progressively-disclosed end-date input
* shown only for RANGE. Living in `$lib/shared/primitives/` keeps it out of either
* consumer's domain so neither incurs a cross-domain import (eslint boundaries).
*
* Exposed (shared contract — both WhoWhenSection and EventForm depend on it):
* - dateIso, precision, endDateIso — $bindable; the parent's binding IS the
* state (no redundant $state mirror).
* - dateInputName / endDateInputName / precisionInputName — submitted field
* names; defaults match the document form (`metaDatePrecision`), the timeline
* form overrides precisionInputName to `precision`.
* - initialDateIso / suggestedDateIso — seeding inputs (see onMount + $effect).
* - dateTestId / precisionTestId / endDateInnerTestId — forwarded data-testid
* attributes so existing WhoWhenSection selectors survive the extraction.
* - `end-date-region` is always on the OUTER aria-live wrapper of the end block.
*/
let {
dateIso = $bindable(''),
precision = $bindable<DatePrecision>('DAY'),
endDateIso = $bindable(''),
dateInputName = 'documentDate',
endDateInputName = 'metaDateEnd',
precisionInputName = 'metaDatePrecision',
initialDateIso = '',
suggestedDateIso = '',
dateLabel = m.form_label_date(),
dateRequired = true,
dateError = '',
endDateError = '',
onchange = undefined,
dateTestId = undefined,
precisionTestId = undefined,
endDateInnerTestId = undefined
}: {
dateIso?: string;
precision?: DatePrecision;
endDateIso?: string;
dateInputName?: string;
endDateInputName?: string;
precisionInputName?: string;
initialDateIso?: string;
suggestedDateIso?: string;
dateLabel?: string;
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;
precisionTestId?: string;
endDateInnerTestId?: string;
} = $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;
if (seed) {
dateDisplay = isoToGerman(seed);
if (!dateIso) dateIso = seed;
}
if (endDateIso) endDisplay = isoToGerman(endDateIso);
});
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
// Either the client-side malformed-date cue or a server-provided required-field
// error marks the field invalid (REQ-011 per-field aria-invalid).
const dateFieldInvalid = $derived(dateInvalid || dateError.length > 0);
// 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
);
// 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);
dateDisplay = result.display;
dateIso = result.iso;
dateDirty = true;
onchange?.();
}
function handleEndDateInput(e: Event) {
const result = handleGermanDateInput(e);
endDisplay = result.display;
endDateIso = result.iso;
onchange?.();
}
$effect(() => {
const suggested = suggestedDateIso;
if (suggested && !untrack(() => dateDirty)) {
dateDisplay = isoToGerman(suggested);
dateIso = suggested;
}
});
</script>
<!-- Datum (required) -->
<div data-testid={dateTestId}>
<label for={dateInputName} class="mb-1 block text-sm font-medium text-ink-2"
>{dateLabel}{#if dateRequired}*{/if}</label
>
<input
id={dateInputName}
type="text"
inputmode="numeric"
value={dateDisplay}
oninput={handleDateInput}
placeholder={m.form_placeholder_date()}
maxlength="10"
aria-required={dateRequired ? 'true' : undefined}
class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm
{dateFieldInvalid
? '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'}"
aria-invalid={dateFieldInvalid ? 'true' : undefined}
aria-describedby={dateFieldInvalid ? `${dateInputName}-error` : undefined}
/>
<input type="hidden" name={dateInputName} value={dateIso} />
{#if dateInvalid}
<p id="{dateInputName}-error" class="mt-1 text-xs text-danger">
<span aria-hidden="true"></span>{m.form_date_error()}
</p>
{:else if dateError}
<p id="{dateInputName}-error" class="mt-1 text-xs text-danger">
<span aria-hidden="true"></span>{dateError}
</p>
{/if}
</div>
<!-- Datumsgenauigkeit (precision) -->
<div data-testid={precisionTestId}>
<label for="{dateInputName}Precision" class="mb-1 block text-sm font-medium text-ink-2">
{m.form_label_date_precision()}
</label>
<select
id="{dateInputName}Precision"
name={precisionInputName}
bind:value={precision}
onchange={() => onchange?.()}
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"
>
{#each PRECISIONS as p (p.value)}
<option value={p.value}>{p.label()}</option>
{/each}
</select>
</div>
<!-- Enddatum: progressive disclosure, revealed only for RANGE, announced politely. -->
<div aria-live="polite" data-testid="end-date-region">
{#if showEndDate}
<div data-testid={endDateInnerTestId}>
<label for="{dateInputName}End" class="mb-1 block text-sm font-medium text-ink-2">
{m.form_label_date_end()}
</label>
<input
id="{dateInputName}End"
type="text"
inputmode="numeric"
value={endDisplay}
oninput={handleEndDateInput}
placeholder={m.form_placeholder_date()}
maxlength="10"
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
{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'}"
/>
{#if endBeforeStart}
<!-- Non-colour cue (WCAG 1.4.1): warning glyph + text, not red alone. -->
<p id="{dateInputName}-end-error" class="mt-1 text-xs text-danger">
<span aria-hidden="true"></span>{m.error_invalid_date_range()}
</p>
{:else if endDateError}
<p id="{dateInputName}-end-error" class="mt-1 text-xs text-danger">
<span aria-hidden="true"></span>{endDateError}
</p>
{/if}
</div>
{/if}
</div>
<!-- Off-RANGE submits an empty string so a stale end-date never persists; the
form action converts '' → null before sending the request body. -->
<input type="hidden" name={endDateInputName} value={showEndDate ? endDateIso : ''} />

View File

@@ -1,3 +1,5 @@
import { error } from '@sveltejs/kit';
/**
* Server-side permission predicates derived from the authenticated user in `locals`.
*
@@ -12,3 +14,17 @@ type PermissionLocals = {
export function hasWriteAll(locals: PermissionLocals): boolean {
return locals.user?.groups?.some((group) => group.permissions.includes('WRITE_ALL')) ?? false;
}
/**
* Throws a 403 unless the user holds WRITE_ALL. Anonymous users are rejected too
* — `hasWriteAll` returns false for a null user, so a single check covers both
* the unauthenticated and the under-privileged case. Server-side gate; the
* frontend canWrite flag only hides entry-point buttons.
*
* Other WRITE_ALL-gated author loads (e.g. `documents/[id]/edit`) still inline
* `if (!hasWriteAll(locals)) throw error(403)` — they can adopt this helper so
* the guard doesn't quietly diverge across routes.
*/
export function requireWriteAll(locals: PermissionLocals): void {
if (!hasWriteAll(locals)) throw error(403, 'Forbidden');
}

View File

@@ -0,0 +1,351 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { beforeNavigate } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
import { toPersonOption, type PersonOption } from '$lib/person/personOption';
import { type DocumentOption } from '$lib/document/documentTypeahead';
import { getConfirmService } from '$lib/shared/services/confirm.svelte.js';
import PersonMultiSelect from '$lib/person/PersonMultiSelect.svelte';
import DocumentMultiSelect from '$lib/document/DocumentMultiSelect.svelte';
import DatePrecisionField from '$lib/shared/primitives/DatePrecisionField.svelte';
import EventTypeSelect from '$lib/timeline/EventTypeSelect.svelte';
import BackButton from '$lib/shared/primitives/BackButton.svelte';
type TimelineEventView = components['schemas']['TimelineEventView'];
/**
* Curator create/edit form for a timeline event. One component, two routes:
* `/new` renders it empty, `/[id]/edit` renders it seeded with `event`. The
* markup is never forked. All data flows through the route's +page.server.ts
* load + form action (SSR) — there is no client fetch('/api/...') here.
*/
interface FormResult {
error?: string;
titleError?: string;
dateError?: string;
endDateError?: string;
title?: string;
description?: string;
type?: string;
// When-section values preserved across a fail(400) so a no-JS reload re-seeds them.
eventDate?: string;
precision?: string;
eventDateEnd?: string | null;
personIds?: string[];
documentIds?: string[];
// Rehydrated chip data (id + label) so the pickers re-render after a fail(400)
// even on a no-JS full reload — bare ids alone can't rebuild a chip (REQ-010).
persons?: PersonOption[];
documents?: DocumentOption[];
}
let {
event = undefined,
initialPersons = [],
initialDocuments = [],
originPersonId = '',
form = null
}: {
event?: TimelineEventView;
initialPersons?: PersonOption[];
initialDocuments?: DocumentOption[];
originPersonId?: string;
form?: FormResult | null;
} = $props();
// Initial-state snapshot from incoming props, preferring a preserved fail payload
// over the seeded `event`. This component is intentionally single-shot: props are
// snapshotted into $state once, so a parent re-render with a different `event`
// won't update the form — the two dedicated routes always remount, which is fine.
let title = $state(form?.title ?? event?.title ?? '');
let description = $state(form?.description ?? event?.description ?? '');
let dateIso = $state(form?.eventDate ?? event?.eventDate ?? '');
let precision = $state<DatePrecision>(
(form?.precision as DatePrecision) ?? (event?.precision as DatePrecision) ?? 'DAY'
);
let endDateIso = $state(form?.eventDateEnd ?? event?.eventDateEnd ?? '');
// On a fail(400) the server returns rehydrated chip data (form.persons/documents)
// so the pickers survive the round-trip — even without JS — ahead of the seeded
// `event` or the prefill initials (REQ-010 / Decision 6).
let selectedPersons = $state<PersonOption[]>(
form?.persons ?? (event?.persons ? event.persons.map(toPersonOption) : initialPersons)
);
let selectedDocuments = $state<DocumentOption[]>(
form?.documents ??
(event?.documents
? event.documents.map((d) => ({
// Graceful degradation: DocumentRef has no precision fields. formatDocumentOption
// defaults a missing precision to DAY, so the chip shows the full documentDate.
id: d.id,
title: d.title,
documentDate: d.documentDate
}))
: initialDocuments)
);
const isEdit = $derived(event !== undefined);
// Captured at init — Svelte context is init-only, so reading it lazily inside an
// event handler throws even when <ConfirmDialog> is mounted. Gates the delete.
const { confirm } = getConfirmService();
let titleTouched = $state(false);
let submitting = $state(false);
let dirty = $state(false);
const titleEmpty = $derived(title.trim().length === 0);
// Required-field errors derive from the CURRENT field value, not the stale server
// payload: a server titleError/dateError seeds the message, but typing a valid
// value clears it immediately instead of sticking until the next submit.
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) {
const ok = window.confirm(m.event_editor_unsaved_changes());
if (!ok) cancel();
}
});
// Every editable control routes its change through markDirty so the
// beforeNavigate guard catches edits to the date/precision/end-date and the
// pickers too — not just title/type/description (their onchange callbacks call
// this). No $effect: marking dirty from the actual edit events avoids a
// snapshot-vs-effect mount-timing trap.
function markDirty() {
dirty = true;
}
</script>
<div class="mx-auto max-w-5xl px-4 py-8">
<div class="mb-6">
<BackButton />
</div>
<h1 class="mb-6 font-serif text-3xl font-bold text-ink">
{isEdit ? m.event_editor_edit_title() : m.event_editor_new_title()}
</h1>
{#if form?.error}
<p
class="mb-4 rounded-sm border border-danger/40 bg-danger/10 px-4 py-3 text-sm text-danger"
role="alert"
>
{form.error}
</p>
{/if}
<form
method="POST"
action="?/save"
use:enhance={({ cancel }) => {
// Client-side guard against a blank title. enhance ignores onsubmit
// preventDefault(), so cancel() is the only thing that actually stops the
// POST; the server still re-validates and owns the authoritative fail(400).
titleTouched = true;
if (titleEmpty) {
cancel();
return;
}
submitting = true;
return async ({ update }) => {
submitting = false;
dirty = false;
await update();
};
}}
>
<input type="hidden" name="originPersonId" value={originPersonId} />
{#if event}
<!-- Optimistic-lock version travels back to the PUT so #3 can reject a
stale edit with 409. -->
<input type="hidden" name="version" value={event.version} />
{/if}
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]">
<!-- Main column -->
<div class="flex flex-col gap-6">
<!-- Titel + Typ + Datum -->
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.event_editor_section_when()}
</h2>
<div class="mb-5">
<label for="event-title" class="mb-1 block text-sm font-medium text-ink-2">
{m.event_editor_title_label()}*
</label>
<input
id="event-title"
name="title"
type="text"
bind:value={title}
oninput={markDirty}
onblur={() => (titleTouched = true)}
maxlength="255"
placeholder={m.event_editor_title_placeholder()}
aria-required="true"
aria-invalid={titleError ? 'true' : undefined}
aria-describedby={titleError ? 'event-title-error' : undefined}
class="block min-h-[48px] w-full rounded border border-line px-3 py-3 text-base shadow-sm
{titleError
? '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 titleError}
<p id="event-title-error" class="mt-1 text-sm text-danger" role="alert">
<span aria-hidden="true"></span>{titleError}
</p>
{/if}
</div>
<div class="mb-5">
<span class="mb-1 block text-sm font-medium text-ink-2"
>{m.event_editor_type_label()}</span
>
<EventTypeSelect
value={form?.type ?? event?.type ?? 'PERSONAL'}
name="type"
onchange={markDirty}
/>
</div>
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
<DatePrecisionField
bind:dateIso={dateIso}
bind:precision={precision}
bind:endDateIso={endDateIso}
dateInputName="eventDate"
endDateInputName="eventDateEnd"
precisionInputName="precision"
dateLabel={m.form_label_date()}
dateError={dateError}
endDateError={endDateError}
onchange={markDirty}
dateTestId="event-date"
precisionTestId="event-precision"
endDateInnerTestId="event-end-date"
/>
</div>
</div>
<!-- Beschreibung -->
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.event_editor_section_description()}
</h2>
<label for="event-description" class="sr-only">{m.event_editor_description_label()}</label
>
<textarea
id="event-description"
name="description"
bind:value={description}
oninput={markDirty}
rows="4"
placeholder={m.event_editor_description_placeholder()}
class="block w-full rounded border border-line px-3 py-3 text-base shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
></textarea>
</div>
</div>
<!-- Sidebar -->
<div class="flex flex-col gap-6">
<!-- Beteiligte Personen -->
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.event_editor_section_persons()}
</h2>
<label for="event-persons-input" class="mb-1 block text-sm font-medium text-ink-2">
{m.event_editor_persons_label()}
</label>
<PersonMultiSelect
bind:selectedPersons={selectedPersons}
inputId="event-persons-input"
hiddenInputName="personIds"
emptyLabel={m.event_editor_persons_empty()}
onchange={markDirty}
/>
</div>
<!-- Verknüpfte Briefe -->
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.event_editor_section_documents()}
</h2>
<label for="event-documents-input" class="mb-1 block text-sm font-medium text-ink-2">
{m.event_editor_documents_label()}
</label>
<DocumentMultiSelect
bind:selectedDocuments={selectedDocuments}
inputId="event-documents-input"
hiddenInputName="documentIds"
emptyLabel={m.event_editor_documents_empty()}
onchange={markDirty}
/>
</div>
</div>
</div>
<!-- Save bar -->
<div
class="sticky bottom-0 z-10 -mx-4 mt-6 flex flex-col gap-3 border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:flex-row sm:items-center sm:justify-between"
>
<p class="font-sans text-xs text-ink-3">{m.event_editor_save_hint()}</p>
<div class="flex items-center gap-2">
<button
type="submit"
disabled={submitting}
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{m.event_editor_save()}
</button>
</div>
</div>
</form>
{#if isEdit}
<!-- Delete lives in its own form so it posts to the dedicated ?/delete action.
The confirm gate runs inside the enhance submit phase: enhance ignores an
onsubmit preventDefault(), so awaiting confirm() and calling cancel() is the
only thing that actually stops the destructive POST. -->
<form
method="POST"
action="?/delete"
use:enhance={async ({ cancel }) => {
const ok = await confirm({
title: m.event_editor_delete_confirm_title(),
body: m.event_editor_delete_confirm_body(),
destructive: true,
confirmLabel: m.event_editor_delete()
});
if (!ok) {
cancel();
return;
}
return async ({ update }) => {
// Clear dirtiness so beforeNavigate doesn't prompt "unsaved changes"
// on the post-delete redirect.
dirty = false;
await update();
};
}}
class="mt-4"
>
<input type="hidden" name="originPersonId" value={originPersonId} />
<button
type="submit"
class="inline-flex h-11 items-center rounded border border-danger/40 px-4 font-sans text-sm font-medium text-danger hover:bg-danger/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{m.event_editor_delete()}
</button>
</form>
{/if}
</div>

View File

@@ -0,0 +1,235 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import EventForm from './EventForm.svelte';
import {
createConfirmService,
CONFIRM_KEY,
type ConfirmService
} from '$lib/shared/services/confirm.svelte.js';
import type { components } from '$lib/generated/api';
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
type TimelineEventView = components['schemas']['TimelineEventView'];
/**
* EventForm captures the confirm service at init (`getConfirmService()`), so every
* render must provide a CONFIRM_KEY context — mirrors documents/[id]/edit's spec.
* The returned service is handed back so delete tests can drive `settle()`.
*/
function renderForm(props: Record<string, unknown> = {}): ConfirmService {
const service = createConfirmService();
render(EventForm, { props, context: new Map([[CONFIRM_KEY, service]]) });
return service;
}
/**
* Minimal TimelineEventView shape used to seed the edit form. Mirrors
* components['schemas']['TimelineEventView'] — all server-populated fields.
*/
function makeEvent(overrides: Partial<TimelineEventView> = {}): TimelineEventView {
return {
id: 'e1',
title: 'Umzug nach Berlin',
type: 'PERSONAL',
eventDate: '1925-04-01',
precision: 'DAY',
version: 0,
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00Z',
updatedBy: 'u1',
updatedAt: '2026-01-01T00:00:00Z',
persons: [],
documents: [],
...overrides
};
}
describe('EventForm — date precision RANGE reveal (headline AC, REQ-008/009)', () => {
it('reveals the end-date field when precision is RANGE', async () => {
renderForm({ event: makeEvent({ precision: 'RANGE', eventDateEnd: '1925-05-01' }) });
await expect.element(page.getByLabelText('Enddatum')).toBeVisible();
});
it('hides the end-date field when precision is YEAR', async () => {
renderForm({ event: makeEvent({ precision: 'YEAR' }) });
await expect.element(page.getByTestId('end-date-region')).toBeInTheDocument();
await expect.element(page.getByLabelText('Enddatum')).not.toBeInTheDocument();
});
});
describe('EventForm — type seeding (review #8 refactor fence)', () => {
it('seeds the type selector from the event so the submitted type is correct', async () => {
renderForm({ event: makeEvent({ type: 'HISTORICAL' }) });
const hidden = document.querySelector('input[type="hidden"][name="type"]') as HTMLInputElement;
expect(hidden.value).toBe('HISTORICAL');
});
it('prefers the fail-payload type over the seeded event', async () => {
renderForm({ event: makeEvent({ type: 'PERSONAL' }), form: { type: 'HISTORICAL' } });
const hidden = document.querySelector('input[type="hidden"][name="type"]') as HTMLInputElement;
expect(hidden.value).toBe('HISTORICAL');
});
});
describe('EventForm — picker preselect (REQ-014)', () => {
it('preselects a person when initialPersons is provided', async () => {
renderForm({ initialPersons: [{ id: 'p1', displayName: 'Anna Müller' }] });
await expect.element(page.getByText('Anna Müller')).toBeInTheDocument();
});
});
describe('EventForm — required-field error (REQ-010)', () => {
it('shows a required-field error when title is blank and save is attempted', async () => {
renderForm({});
await page.getByRole('button', { name: 'Speichern' }).click();
await expect.element(page.getByText('Bitte einen Titel eingeben.')).toBeInTheDocument();
});
it('cancels the submission — fires no network POST — when title is blank', async () => {
// The client-side guard must actually CANCEL the enhance submission, not just
// show a message: enhance ignores onsubmit preventDefault(), so without cancel()
// a blank-title Save still POSTs (and update()->applyAction crashes with no app).
const fetchSpy = vi.fn(() => new Promise<Response>(() => {}));
vi.stubGlobal('fetch', fetchSpy);
renderForm({});
await page.getByRole('button', { name: 'Speichern' }).click();
await expect.element(page.getByText('Bitte einen Titel eingeben.')).toBeInTheDocument();
expect(fetchSpy).not.toHaveBeenCalled();
});
it('rehydrates the pickers from the fail payload (Decision 6)', async () => {
renderForm({
form: {
titleError: 'Bitte einen Titel eingeben.',
title: '',
persons: [{ id: 'p1', displayName: 'Anna Müller' }],
documents: [{ id: 'd1', title: 'Brief A', documentDate: '1925-04-01' }]
}
});
await expect.element(page.getByText('Anna Müller')).toBeInTheDocument();
await expect.element(page.getByText(/Brief A/)).toBeInTheDocument();
});
});
describe('EventForm — delete is gated behind confirmation (REQ-006)', () => {
it('fires no DELETE until the user confirms, and not at all if they cancel', async () => {
// The DELETE must wait for the confirm dialog. enhance ignores onsubmit
// preventDefault(), so the only correct gate is awaiting confirm() inside the
// enhance submit phase and calling cancel() on a "no" — anything else POSTs
// the destructive DELETE on the bare click, before the dialog is answered.
const fetchSpy = vi.fn(() => new Promise<Response>(() => {}));
vi.stubGlobal('fetch', fetchSpy);
const service = renderForm({ event: makeEvent() });
await page.getByRole('button', { name: 'Löschen' }).click();
// Dialog is pending — nothing must have been POSTed yet.
expect(fetchSpy).not.toHaveBeenCalled();
service.settle(false); // user cancels
await new Promise((r) => setTimeout(r, 0));
expect(fetchSpy).not.toHaveBeenCalled();
});
it('fires the DELETE once the user confirms', async () => {
const fetchSpy = vi.fn(() => new Promise<Response>(() => {}));
vi.stubGlobal('fetch', fetchSpy);
const service = renderForm({ event: makeEvent() });
await page.getByRole('button', { name: 'Löschen' }).click();
service.settle(true); // user confirms
await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalled());
});
});
describe('EventForm — seeds the When-section from the fail payload (review #2 — no-JS)', () => {
it('re-seeds date, precision and end-date from the form payload on /new', async () => {
renderForm({
form: { eventDate: '1944-03-12', precision: 'RANGE', eventDateEnd: '1944-03-14' }
});
// precision=RANGE seeded → end-date field revealed (proves precision survived).
await expect.element(page.getByLabelText('Enddatum')).toBeVisible();
const dateInput = document.querySelector('#eventDate') as HTMLInputElement;
expect(dateInput.value).toBe('12.03.1944');
const endInput = document.querySelector('#eventDateEnd') as HTMLInputElement;
expect(endInput.value).toBe('14.03.1944');
});
});
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.' } });
await expect.element(page.getByText('Bitte ein Datum eingeben.')).toBeInTheDocument();
const dateInput = document.querySelector('#eventDate') as HTMLInputElement;
expect(dateInput.getAttribute('aria-invalid')).toBe('true');
});
});
describe('EventForm — validation errors clear on correction (review #4)', () => {
it('clears the server title error once a valid title is entered', async () => {
renderForm({ form: { titleError: 'Bitte einen Titel eingeben.', title: '' } });
await expect.element(page.getByText('Bitte einen Titel eingeben.')).toBeInTheDocument();
const titleInput = document.querySelector('#event-title') as HTMLInputElement;
titleInput.value = 'Ein Titel';
titleInput.dispatchEvent(new Event('input', { bubbles: true }));
await expect.element(page.getByText('Bitte einen Titel eingeben.')).not.toBeInTheDocument();
});
it('clears the server date error once a valid date is entered', async () => {
renderForm({ form: { dateError: 'Bitte ein Datum eingeben.' } });
await expect.element(page.getByText('Bitte ein Datum eingeben.')).toBeInTheDocument();
const dateInput = document.querySelector('#eventDate') as HTMLInputElement;
dateInput.value = '01.04.1925';
dateInput.dispatchEvent(new Event('input', { bubbles: true }));
await expect.element(page.getByText('Bitte ein Datum eingeben.')).not.toBeInTheDocument();
});
});
describe('EventForm — submitting state (named AC, Decision 8)', () => {
it('disables the submit button while submitting', async () => {
// A never-resolving fetch keeps use:enhance in flight so the disabled
// transition (the double-submit guard) is observable rather than racing the
// reset in the result callback.
vi.stubGlobal(
'fetch',
vi.fn(() => new Promise(() => {}))
);
renderForm({ event: makeEvent() });
const btn = page.getByRole('button', { name: 'Speichern' });
await expect.element(btn).not.toBeDisabled();
await btn.click();
await expect.element(btn).toBeDisabled();
});
});
describe('EventForm — server error surfaced inline (REQ-007/013)', () => {
it('renders the mapped error from the form prop', async () => {
renderForm({ event: makeEvent(), form: { error: 'Etwas ist schiefgelaufen.' } });
await expect.element(page.getByText('Etwas ist schiefgelaufen.')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import { untrack } from 'svelte';
import { radioGroupNav } from '$lib/shared/actions/radioGroupNav';
import { m } from '$lib/paraglide/messages.js';
type EventType = 'PERSONAL' | 'HISTORICAL';
const TYPES: EventType[] = ['PERSONAL', 'HISTORICAL'];
let {
value = 'PERSONAL',
name = 'type',
onchange
}: { value?: string; name?: string; onchange?: (type: EventType) => void } = $props();
let selected = $state<EventType>(
untrack(() => (TYPES.includes(value as EventType) ? (value as EventType) : 'PERSONAL'))
);
let announcement = $state('');
const labels: Record<EventType, () => string> = {
PERSONAL: m.event_type_PERSONAL,
HISTORICAL: m.event_type_HISTORICAL
};
// Decorative accents only — never the sole differentiator (text label is always
// present). aria-hidden so AT announces the label, not the glyph.
const icons: Record<EventType, string> = {
PERSONAL: '👤',
HISTORICAL: '🏛'
};
function select(type: EventType) {
selected = type;
announcement = m.a11y_type_changed({ type: labels[type]() });
onchange?.(type);
}
</script>
<div
role="radiogroup"
aria-label={m.event_editor_type_label()}
class="grid grid-cols-2 gap-2"
use:radioGroupNav={(v) => {
if (TYPES.includes(v as EventType)) select(v as EventType);
}}
>
{#each TYPES as type (type)}
<button
type="button"
role="radio"
value={type}
aria-checked={selected === type}
tabindex={selected === type ? 0 : -1}
onclick={() => select(type)}
class="flex min-h-[48px] cursor-pointer items-center gap-2 rounded-sm border px-3 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none {selected ===
type
? 'border-primary bg-primary text-primary-fg'
: 'border-line bg-surface text-ink hover:border-primary/50'}"
>
<span class="text-lg leading-none" aria-hidden="true">{icons[type]}</span>
<span>{labels[type]()}</span>
</button>
{/each}
</div>
<input type="hidden" name={name} value={selected} />
<div class="sr-only" aria-live="polite" aria-atomic="true">{announcement}</div>

View File

@@ -0,0 +1,27 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import EventTypeSelect from './EventTypeSelect.svelte';
afterEach(() => cleanup());
describe('EventTypeSelect — segmented PERSONAL/HISTORICAL radio', () => {
it('renders exactly two radio options', async () => {
render(EventTypeSelect, { value: 'PERSONAL' });
const radios = document.querySelectorAll('[role="radio"]');
expect(radios.length).toBe(2);
});
it('marks the initial value as checked and seeds the hidden input', async () => {
render(EventTypeSelect, { value: 'HISTORICAL', name: 'type' });
const hidden = document.querySelector('input[type="hidden"][name="type"]') as HTMLInputElement;
expect(hidden.value).toBe('HISTORICAL');
});
it('selects HISTORICAL and updates the hidden input when clicked', async () => {
render(EventTypeSelect, { value: 'PERSONAL', name: 'type' });
await page.getByRole('radio', { name: 'Historisch' }).click();
const hidden = document.querySelector('input[type="hidden"][name="type"]') as HTMLInputElement;
expect(hidden.value).toBe('HISTORICAL');
});
});

View File

@@ -0,0 +1,154 @@
import { m } from '$lib/paraglide/messages.js';
import { createApiClient } from '$lib/shared/api.server';
import type { components } from '$lib/generated/api';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
import type { PersonOption } from '$lib/person/personOption';
import type { DocumentOption } from '$lib/document/documentTypeahead';
type TimelineEventRequest = components['schemas']['TimelineEventRequest'];
type ApiClient = ReturnType<typeof createApiClient>;
// Prevents open redirect: validate before constructing /persons/{id}. See OWASP CWE-601.
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// Whitelist of accepted precision tokens — mirrors the DatePrecision union. Any
// other submitted value falls back to DAY rather than flowing untrusted into the
// request body (the backend enum is the hard gate; this keeps the two symmetric
// with the `type` narrowing below).
const VALID_PRECISIONS: readonly DatePrecision[] = [
'DAY',
'MONTH',
'SEASON',
'YEAR',
'RANGE',
'APPROX',
'UNKNOWN'
];
/**
* Resolves the context-aware post-save / post-delete redirect target. Returns
* the originating person page only when `originPersonIdRaw` is a strict UUID;
* otherwise falls back to the timeline (open-redirect guard).
*/
export function resolveNavTarget(originPersonIdRaw: string): string {
return UUID_RE.test(originPersonIdRaw) ? `/persons/${originPersonIdRaw}` : '/zeitstrahl';
}
export interface ParsedEventForm {
title: string;
type: 'PERSONAL' | 'HISTORICAL';
eventDate: string;
precision: DatePrecision;
eventDateEnd: string | null;
description: string;
personIds: string[];
documentIds: string[];
originPersonId: string;
}
/** Reads the curator event form fields out of submitted FormData. */
export function parseEventForm(formData: FormData): ParsedEventForm {
const rawType = formData.get('type')?.toString();
const type = rawType === 'HISTORICAL' ? 'HISTORICAL' : 'PERSONAL';
const rawPrecision = formData.get('precision')?.toString() as DatePrecision | undefined;
const precision: DatePrecision =
rawPrecision && VALID_PRECISIONS.includes(rawPrecision) ? rawPrecision : 'DAY';
const endRaw = formData.get('eventDateEnd')?.toString().trim() ?? '';
// Off-RANGE submits an empty string → null so a stale end-date never persists.
const eventDateEnd = precision === 'RANGE' && endRaw ? endRaw : null;
return {
title: formData.get('title')?.toString().trim() ?? '',
type,
eventDate: formData.get('eventDate')?.toString().trim() ?? '',
precision,
eventDateEnd,
description: formData.get('description')?.toString().trim() ?? '',
personIds: formData.getAll('personIds').map((v) => v.toString()),
documentIds: formData.getAll('documentIds').map((v) => v.toString()),
originPersonId: formData.get('originPersonId')?.toString() ?? ''
};
}
/**
* 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; 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() : '';
// 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. */
export function preservedFormFields(parsed: ParsedEventForm) {
return {
title: parsed.title,
description: parsed.description,
type: parsed.type,
// The When-section too, so a no-JS full reload re-seeds the date controls
// instead of dropping the curator's date/precision/end-date.
eventDate: parsed.eventDate,
precision: parsed.precision,
eventDateEnd: parsed.eventDateEnd,
personIds: parsed.personIds,
documentIds: parsed.documentIds
};
}
/**
* Re-fetches the selected persons/documents by id so a `fail(400)` can re-render
* the pickers with full chip labels — the form only resubmits bare ids, which
* cannot rebuild a chip on their own (Decision 6 / REQ-010). Non-ok lookups are
* swallowed: a since-deleted id silently drops from the picker rather than
* leaking existence, mirroring the prefill path in the new-route load.
*/
export async function lookupSelections(
api: ApiClient,
personIds: string[],
documentIds: string[]
): Promise<{ persons: PersonOption[]; documents: DocumentOption[] }> {
const [personResults, documentResults] = await Promise.all([
Promise.all(personIds.map((id) => api.GET('/api/persons/{id}', { params: { path: { id } } }))),
Promise.all(
documentIds.map((id) => api.GET('/api/documents/{id}', { params: { path: { id } } }))
)
]);
return {
persons: personResults.filter((r) => r.response.ok && r.data).map((r) => r.data!),
documents: documentResults
.filter((r) => r.response.ok && r.data)
.map((r) => ({
id: r.data!.id,
title: r.data!.title,
documentDate: r.data!.documentDate,
metaDatePrecision: r.data!.metaDatePrecision,
metaDateEnd: r.data!.metaDateEnd
}))
};
}
/** Builds the TimelineEventRequest write body from parsed form fields. */
export function toEventRequest(parsed: ParsedEventForm, version?: number): TimelineEventRequest {
return {
title: parsed.title,
type: parsed.type,
eventDate: parsed.eventDate,
precision: parsed.precision,
eventDateEnd: parsed.eventDateEnd,
...(parsed.description ? { description: parsed.description } : {}),
...(parsed.personIds.length ? { personIds: parsed.personIds } : {}),
...(parsed.documentIds.length ? { documentIds: parsed.documentIds } : {}),
...(version !== undefined ? { version } : {})
} as TimelineEventRequest;
}

View File

@@ -0,0 +1,107 @@
import { error, fail, redirect } from '@sveltejs/kit';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
import { requireWriteAll } from '$lib/shared/server/permissions';
import {
parseEventForm,
validateEventForm,
preservedFormFields,
lookupSelections,
toEventRequest,
resolveNavTarget
} from '$lib/timeline/eventFormServer';
export async function load({
locals,
params,
url,
fetch
}: {
locals: App.Locals;
params: { id: string };
url: URL;
fetch: typeof globalThis.fetch;
}) {
requireWriteAll(locals);
const api = createApiClient(fetch);
const result = await api.GET('/api/timeline/events/{id}', {
params: { path: { id: params.id } }
});
// Fail closed: derived person-events (Geburt/Tod/Heirat) are not persisted and
// have no UUID, so the API 404s for them. Any non-ok response → 404; never
// render a blank editable form that silently POSTs a new event.
if (!result.response.ok) throw error(404, 'Not found');
return { event: result.data!, originPersonId: url.searchParams.get('personId') ?? '' };
}
export const actions = {
save: async ({
request,
params,
fetch
}: {
request: Request;
params: { id: string };
fetch: typeof globalThis.fetch;
}) => {
const formData = await request.formData();
const parsed = parseEventForm(formData);
const api = createApiClient(fetch);
const errors = validateEventForm(parsed);
if (errors) {
const { persons, documents } = await lookupSelections(
api,
parsed.personIds,
parsed.documentIds
);
return fail(400, { ...errors, ...preservedFormFields(parsed), persons, documents });
}
const versionRaw = formData.get('version')?.toString();
const version = versionRaw ? Number(versionRaw) : undefined;
const result = await api.PUT('/api/timeline/events/{id}', {
params: { path: { id: params.id } },
body: toEventRequest(parsed, version)
});
if (!result.response.ok) {
return fail(result.response.status, {
error: getErrorMessage(extractErrorCode(result.error)),
...preservedFormFields(parsed)
});
}
throw redirect(303, resolveNavTarget(parsed.originPersonId));
},
delete: async ({
request,
params,
fetch
}: {
request: Request;
params: { id: string };
fetch: typeof globalThis.fetch;
}) => {
const formData = await request.formData();
const originPersonId = formData.get('originPersonId')?.toString() ?? '';
const api = createApiClient(fetch);
const result = await api.DELETE('/api/timeline/events/{id}', {
params: { path: { id: params.id } }
});
if (!result.response.ok) {
return fail(result.response.status, {
error: getErrorMessage(extractErrorCode(result.error))
});
}
throw redirect(303, resolveNavTarget(originPersonId));
}
};

View File

@@ -0,0 +1,8 @@
<script lang="ts">
import EventForm from '$lib/timeline/EventForm.svelte';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
</script>
<EventForm event={data.event} originPersonId={data.originPersonId} form={form} />

View File

@@ -0,0 +1,158 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('$lib/shared/api.server', () => ({
createApiClient: vi.fn(),
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
import { createApiClient } from '$lib/shared/api.server';
import { load, actions } from './+page.server';
const mockFetch = vi.fn() as unknown as typeof fetch;
// fail() returns a union type that TS won't narrow; read its data loosely.
const failData = (r: unknown) => (r as { data: Record<string, unknown> }).data;
beforeEach(() => vi.clearAllMocks());
function localsWith(perms: string[] | null) {
if (perms === null) return { user: null };
return { user: { groups: [{ permissions: perms }] } };
}
function loadEvent(perms: string[] | null, id = 'e1') {
const url = new URL(`http://localhost/zeitstrahl/events/${id}/edit`);
return {
locals: localsWith(perms),
url,
params: { id },
fetch: mockFetch,
request: new Request(url),
route: { id: '/zeitstrahl/events/[id]/edit' }
} as never;
}
function actionEvent(
method: 'save' | 'delete',
fields: Record<string, string | string[]>,
id = 'e1'
) {
const fd = new FormData();
for (const [k, v] of Object.entries(fields)) {
if (Array.isArray(v)) v.forEach((x) => fd.append(k, x));
else fd.set(k, v);
}
return {
request: new Request(`http://localhost/zeitstrahl/events/${id}/edit?/${method}`, {
method: 'POST',
body: fd
}),
params: { id },
fetch: mockFetch,
route: { id: '/zeitstrahl/events/[id]/edit' }
} as never;
}
const EVENT_VIEW = {
id: 'e1',
title: 'Umzug',
type: 'PERSONAL',
eventDate: '1925-04-01',
precision: 'DAY',
version: 2,
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00Z',
updatedBy: 'u1',
updatedAt: '2026-01-01T00:00:00Z',
persons: [],
documents: []
};
describe('zeitstrahl/events/[id]/edit load — gating (REQ-002/003)', () => {
it('throws 403 for an unauthenticated (null) user', async () => {
await expect(load(loadEvent(null))).rejects.toMatchObject({ status: 403 });
});
it('throws 403 for a user without WRITE_ALL', async () => {
await expect(load(loadEvent(['READ_ALL']))).rejects.toMatchObject({ status: 403 });
});
});
describe('zeitstrahl/events/[id]/edit load — fail closed (REQ-012)', () => {
it('throws 404 when the GET is not ok (unknown or derived id)', async () => {
vi.mocked(createApiClient).mockReturnValue({
GET: vi.fn().mockResolvedValue({ response: { ok: false, status: 404 }, data: undefined })
} as never);
await expect(load(loadEvent(['WRITE_ALL']))).rejects.toMatchObject({ status: 404 });
});
it('seeds the form with the event on an ok GET', async () => {
vi.mocked(createApiClient).mockReturnValue({
GET: vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: EVENT_VIEW })
} as never);
const result = await load(loadEvent(['WRITE_ALL']));
expect(result.event).toMatchObject({ id: 'e1', title: 'Umzug' });
});
});
describe('zeitstrahl/events/[id]/edit save action (REQ-005/013)', () => {
it('updates via PUT (with version) and redirects on success', async () => {
const put = vi
.fn()
.mockResolvedValue({ response: { ok: true, status: 200 }, data: EVENT_VIEW });
vi.mocked(createApiClient).mockReturnValue({ PUT: put } as never);
await expect(
actions.save(
actionEvent('save', {
title: 'Umzug II',
type: 'PERSONAL',
eventDate: '1925-04-01',
version: '2'
})
)
).rejects.toMatchObject({ status: 303, location: '/zeitstrahl' });
expect(put).toHaveBeenCalledTimes(1);
expect(put.mock.calls[0][1].params.path.id).toBe('e1');
expect(put.mock.calls[0][1].body).toMatchObject({ title: 'Umzug II', version: 2 });
});
it('maps a 409 conflict and does not redirect', async () => {
const put = vi
.fn()
.mockResolvedValue({ response: { ok: false, status: 409 }, error: { code: 'CONFLICT' } });
vi.mocked(createApiClient).mockReturnValue({ PUT: put } as never);
const result = await actions.save(
actionEvent('save', { title: 'Umzug', type: 'PERSONAL', eventDate: '1925-04-01' })
);
expect(result).toMatchObject({ status: 409 });
expect(failData(result).error).toBeTruthy();
});
});
describe('zeitstrahl/events/[id]/edit delete action (REQ-006/007)', () => {
const validUuid = '22222222-2222-2222-2222-222222222222';
it('deletes via DELETE and redirects to the resolved target on success', async () => {
const del = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 } });
vi.mocked(createApiClient).mockReturnValue({ DELETE: del } as never);
await expect(
actions.delete(actionEvent('delete', { originPersonId: validUuid }))
).rejects.toMatchObject({ status: 303, location: `/persons/${validUuid}` });
expect(del.mock.calls[0][1].params.path.id).toBe('e1');
});
it('returns fail(status) and does not redirect when DELETE is not ok', async () => {
const del = vi
.fn()
.mockResolvedValue({ response: { ok: false, status: 500 }, error: { code: 'INTERNAL' } });
vi.mocked(createApiClient).mockReturnValue({ DELETE: del } as never);
const result = await actions.delete(actionEvent('delete', {}));
expect(result).toMatchObject({ status: 500 });
expect(failData(result).error).toBeTruthy();
});
});

View File

@@ -0,0 +1,81 @@
import { fail, redirect } from '@sveltejs/kit';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
import { requireWriteAll } from '$lib/shared/server/permissions';
import {
parseEventForm,
validateEventForm,
preservedFormFields,
lookupSelections,
toEventRequest,
resolveNavTarget
} from '$lib/timeline/eventFormServer';
import type { PersonOption } from '$lib/person/personOption';
import type { DocumentOption } from '$lib/document/documentTypeahead';
export async function load({
locals,
url,
fetch
}: {
locals: App.Locals;
url: URL;
fetch: typeof globalThis.fetch;
}) {
requireWriteAll(locals);
const api = createApiClient(fetch);
const personId = url.searchParams.get('personId');
const documentId = url.searchParams.get('documentId');
const [personResult, documentResult] = await Promise.all([
personId ? api.GET('/api/persons/{id}', { params: { path: { id: personId } } }) : null,
documentId ? api.GET('/api/documents/{id}', { params: { path: { id: documentId } } }) : null
]);
// Silently ignore 404/403 on prefill lookups to avoid leaking entity existence.
const initialPersons: PersonOption[] =
personResult && personResult.response.ok && personResult.data ? [personResult.data] : [];
const initialDocuments: DocumentOption[] =
documentResult && documentResult.response.ok && documentResult.data
? [
{
id: documentResult.data.id,
title: documentResult.data.title,
documentDate: documentResult.data.documentDate,
metaDatePrecision: documentResult.data.metaDatePrecision,
metaDateEnd: documentResult.data.metaDateEnd
}
]
: [];
return { initialPersons, initialDocuments, originPersonId: personId ?? '' };
}
export const actions = {
save: async ({ request, fetch }: { request: Request; fetch: typeof globalThis.fetch }) => {
const parsed = parseEventForm(await request.formData());
const api = createApiClient(fetch);
const errors = validateEventForm(parsed);
if (errors) {
const { persons, documents } = await lookupSelections(
api,
parsed.personIds,
parsed.documentIds
);
return fail(400, { ...errors, ...preservedFormFields(parsed), persons, documents });
}
const result = await api.POST('/api/timeline/events', { body: toEventRequest(parsed) });
if (!result.response.ok) {
return fail(result.response.status, {
error: getErrorMessage(extractErrorCode(result.error)),
...preservedFormFields(parsed)
});
}
throw redirect(303, resolveNavTarget(parsed.originPersonId));
}
};

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import EventForm from '$lib/timeline/EventForm.svelte';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
</script>
<EventForm
initialPersons={data.initialPersons}
initialDocuments={data.initialDocuments}
originPersonId={data.originPersonId}
form={form}
/>

View File

@@ -0,0 +1,288 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('$lib/shared/api.server', () => ({
createApiClient: vi.fn(),
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
import { createApiClient } from '$lib/shared/api.server';
import { load, actions } from './+page.server';
const mockFetch = vi.fn() as unknown as typeof fetch;
// fail() returns a union type that TS won't narrow; read its data loosely.
const failData = (r: unknown) => (r as { data: Record<string, unknown> }).data;
beforeEach(() => vi.clearAllMocks());
function localsWith(perms: string[] | null) {
if (perms === null) return { user: null };
return { user: { groups: [{ permissions: perms }] } };
}
function loadEvent(perms: string[] | null, search = '') {
const url = new URL(`http://localhost/zeitstrahl/events/new${search}`);
return {
locals: localsWith(perms),
url,
fetch: mockFetch,
request: new Request(url),
route: { id: '/zeitstrahl/events/new' },
params: {}
} as never;
}
function saveRequest(fields: Record<string, string | string[]>): Request {
const fd = new FormData();
for (const [k, v] of Object.entries(fields)) {
if (Array.isArray(v)) v.forEach((x) => fd.append(k, x));
else fd.set(k, v);
}
return new Request('http://localhost/zeitstrahl/events/new', { method: 'POST', body: fd });
}
function saveEvent(fields: Record<string, string | string[]>) {
return {
request: saveRequest(fields),
fetch: mockFetch,
route: { id: '/zeitstrahl/events/new' },
params: {}
} as never;
}
describe('zeitstrahl/events/new load — gating (REQ-002/003)', () => {
it('throws 403 for an unauthenticated (null) user', async () => {
await expect(load(loadEvent(null))).rejects.toMatchObject({ status: 403 });
});
it('throws 403 for an authenticated user without WRITE_ALL', async () => {
await expect(load(loadEvent(['READ_ALL']))).rejects.toMatchObject({ status: 403 });
});
it('allows a curator with WRITE_ALL', async () => {
vi.mocked(createApiClient).mockReturnValue({ GET: vi.fn() } as never);
const result = await load(loadEvent(['WRITE_ALL']));
expect(result.initialPersons).toEqual([]);
expect(result.initialDocuments).toEqual([]);
});
});
describe('zeitstrahl/events/new load — prefill (REQ-014)', () => {
it('preselects a valid person and ignores an unknown document', async () => {
const get = vi.fn((path: string) => {
if (path === '/api/persons/{id}')
return Promise.resolve({
response: { ok: true },
data: { id: 'p1', displayName: 'Anna' }
});
return Promise.resolve({ response: { ok: false }, data: null });
});
vi.mocked(createApiClient).mockReturnValue({ GET: get } as never);
const result = await load(loadEvent(['WRITE_ALL'], '?personId=p1&documentId=missing'));
expect(result.initialPersons).toHaveLength(1);
expect(result.initialDocuments).toEqual([]);
expect(result.originPersonId).toBe('p1');
});
});
describe('zeitstrahl/events/new save action (REQ-004/009/010/015)', () => {
const validUuid = '11111111-1111-1111-1111-111111111111';
it('posts a TimelineEventRequest and redirects on success', async () => {
const post = vi
.fn()
.mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'e-new' } });
vi.mocked(createApiClient).mockReturnValue({ POST: post } as never);
await expect(
actions.save(saveEvent({ title: 'Umzug', type: 'PERSONAL', eventDate: '1925-04-01' }))
).rejects.toMatchObject({ status: 303, location: '/zeitstrahl' });
expect(post).toHaveBeenCalledTimes(1);
expect(post.mock.calls[0][1].body).toMatchObject({
title: 'Umzug',
type: 'PERSONAL',
eventDate: '1925-04-01'
});
});
it('sends eventDateEnd: null when precision is not RANGE', async () => {
const post = vi
.fn()
.mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'e-new' } });
vi.mocked(createApiClient).mockReturnValue({ POST: post } as never);
await expect(
actions.save(
saveEvent({
title: 'Umzug',
type: 'PERSONAL',
eventDate: '1925-04-01',
precision: 'YEAR',
eventDateEnd: '1925-05-01'
})
)
).rejects.toMatchObject({ status: 303 });
expect(post.mock.calls[0][1].body.eventDateEnd).toBeNull();
});
it('falls back to DAY precision when an unknown precision token is submitted (REQ-009 hardening)', async () => {
const post = vi
.fn()
.mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'e-new' } });
vi.mocked(createApiClient).mockReturnValue({ POST: post } as never);
await expect(
actions.save(
saveEvent({
title: 'Umzug',
type: 'PERSONAL',
eventDate: '1925-04-01',
precision: 'NOT_A_REAL_PRECISION'
})
)
).rejects.toMatchObject({ status: 303 });
expect(post.mock.calls[0][1].body.precision).toBe('DAY');
});
it('returns fail(400) with preserved + rehydrated pickers on blank title', async () => {
const post = vi.fn();
// On validation failure the action re-fetches the selected persons/documents
// by id so the fail payload can rebuild full chips (Decision 6 / REQ-010).
const get = vi.fn((path: string, opts: { params: { path: { id: string } } }) => {
const id = opts.params.path.id;
if (path === '/api/persons/{id}')
return Promise.resolve({
response: { ok: true },
data: { id, displayName: `Person ${id}` }
});
return Promise.resolve({
response: { ok: true },
data: { id, title: `Doc ${id}`, documentDate: '1925-04-01' }
});
});
vi.mocked(createApiClient).mockReturnValue({ POST: post, GET: get } as never);
const result = await actions.save(
saveEvent({
title: ' ',
type: 'PERSONAL',
eventDate: '1925-04-01',
personIds: ['p1', 'p2'],
documentIds: ['d1']
})
);
expect(post).not.toHaveBeenCalled();
expect(result).toMatchObject({ status: 400 });
expect(failData(result).personIds).toEqual(['p1', 'p2']);
expect(failData(result).documentIds).toEqual(['d1']);
expect(failData(result).titleError).toBeTruthy();
// Rehydrated chips carry labels, not just ids.
expect(failData(result).persons).toEqual([
{ id: 'p1', displayName: 'Person p1' },
{ id: 'p2', displayName: 'Person p2' }
]);
expect((failData(result).documents as { id: string }[])[0].id).toBe('d1');
});
it('preserves date, precision and end-date in the fail payload (review #2 — no-JS reload)', async () => {
// A blank-title fail must echo back the entered When-section so a no-JS full
// reload re-seeds it; otherwise the curator loses the date/precision/end-date.
vi.mocked(createApiClient).mockReturnValue({ POST: vi.fn(), GET: vi.fn() } as never);
const result = await actions.save(
saveEvent({
title: '',
type: 'PERSONAL',
eventDate: '1944-03-12',
precision: 'RANGE',
eventDateEnd: '1944-03-14'
})
);
expect(result).toMatchObject({ status: 400 });
expect(failData(result).eventDate).toBe('1944-03-12');
expect(failData(result).precision).toBe('RANGE');
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: '' }));
expect(failData(result).titleError).toBeTruthy();
expect(failData(result).dateError).toBeTruthy();
});
it('redirects to /persons/{id} when originPersonId is a valid UUID', async () => {
const post = vi
.fn()
.mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'e-new' } });
vi.mocked(createApiClient).mockReturnValue({ POST: post } as never);
await expect(
actions.save(
saveEvent({
title: 'Umzug',
type: 'PERSONAL',
eventDate: '1925-04-01',
originPersonId: validUuid
})
)
).rejects.toMatchObject({ status: 303, location: `/persons/${validUuid}` });
});
it('defaults to /zeitstrahl when originPersonId is not a valid UUID (REQ-015)', async () => {
const post = vi
.fn()
.mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'e-new' } });
vi.mocked(createApiClient).mockReturnValue({ POST: post } as never);
await expect(
actions.save(
saveEvent({
title: 'Umzug',
type: 'PERSONAL',
eventDate: '1925-04-01',
originPersonId: '../evil'
})
)
).rejects.toMatchObject({ status: 303, location: '/zeitstrahl' });
});
it('maps the API error and does not redirect on a non-ok save (incl. 409)', async () => {
const post = vi.fn().mockResolvedValue({
response: { ok: false, status: 409 },
error: { code: 'CONFLICT' }
});
vi.mocked(createApiClient).mockReturnValue({ POST: post } as never);
const result = await actions.save(
saveEvent({ title: 'Umzug', type: 'PERSONAL', eventDate: '1925-04-01' })
);
expect(result).toMatchObject({ status: 409 });
expect(failData(result).error).toBeTruthy();
});
});