Compare commits

..

23 Commits

Author SHA1 Message Date
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 1832 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,31 @@
"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_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,31 @@
"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_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,31 @@
"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_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,211 @@
<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 = '',
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;
/** 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
);
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={endBeforeStart ? 'true' : undefined}
aria-describedby={endBeforeStart ? `${dateInputName}-end-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="{dateInputName}-end-error" class="mt-1 text-xs text-danger">
<span aria-hidden="true"></span>{m.error_invalid_date_range()}
</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,326 @@
<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;
title?: string;
description?: string;
type?: string;
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 type = $state<string>(form?.type ?? event?.type ?? 'PERSONAL');
let dateIso = $state(event?.eventDate ?? '');
let precision = $state<DatePrecision>((event?.precision as DatePrecision) ?? 'DAY');
let endDateIso = $state(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);
let titleTouched = $state(false);
let submitting = $state(false);
let dirty = $state(false);
const titleEmpty = $derived(title.trim().length === 0);
// Client-side title error fires instantly on a save attempt; the server's
// titleError is the simultaneous-multi-field source on a real round-trip.
const titleError = $derived(
form?.titleError ?? (titleTouched && titleEmpty ? m.event_editor_title_required() : '')
);
const dateError = $derived(form?.dateError ?? '');
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;
}
// Guards a submit with a blank title client-side. The server re-validates and
// owns the authoritative fail(400) with per-field flags.
function handleSubmit(e: SubmitEvent) {
titleTouched = true;
if (titleEmpty) {
e.preventDefault();
}
}
async function confirmDelete(e: SubmitEvent) {
e.preventDefault();
const { confirm } = getConfirmService();
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) (e.target as HTMLFormElement).requestSubmit();
}
</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"
onsubmit={handleSubmit}
use:enhance={() => {
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={type} name="type" onchange={(t) => {
type = t;
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}
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.
getConfirmService() is read lazily inside the handler so the component
mounts cleanly outside a layout (tests) where no confirm context exists. -->
<form method="POST" action="?/delete" onsubmit={confirmDelete} use:enhance 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,113 @@
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 type { components } from '$lib/generated/api';
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
type TimelineEventView = components['schemas']['TimelineEventView'];
/**
* 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 () => {
render(EventForm, { 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 () => {
render(EventForm, { event: makeEvent({ precision: 'YEAR' }) });
await expect.element(page.getByTestId('end-date-region')).toBeInTheDocument();
await expect.element(page.getByLabelText('Enddatum')).not.toBeInTheDocument();
});
});
describe('EventForm — picker preselect (REQ-014)', () => {
it('preselects a person when initialPersons is provided', async () => {
render(EventForm, {
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 () => {
render(EventForm, {});
await page.getByRole('button', { name: 'Speichern' }).click();
await expect.element(page.getByText('Bitte einen Titel eingeben.')).toBeInTheDocument();
});
it('rehydrates the pickers from the fail payload (Decision 6)', async () => {
render(EventForm, {
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 — 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 () => {
render(EventForm, { 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 — 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(() => {}))
);
render(EventForm, { 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 () => {
render(EventForm, {
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,143 @@
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 both failing required-field errors (title + date) simultaneously, or
* null when the form is valid. The route owns the `fail(400)` so it can enrich
* the payload with the preserved field values and rehydrated picker selections.
*/
export function validateEventForm(
parsed: ParsedEventForm
): { titleError: string; dateError: string } | null {
const titleError = parsed.title.length === 0 ? m.event_editor_title_required() : '';
const dateError = parsed.eventDate.length === 0 ? m.event_editor_date_required() : '';
if (!titleError && !dateError) return null;
return { titleError, dateError };
}
/** 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,
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,246 @@
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('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();
});
});