Compare commits

..

90 Commits

Author SHA1 Message Date
Marcel
239565ea20 refactor(timeline): single-source the spine X position via --spine-x
All checks were successful
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m9s
SDD Gate / RTM Check (pull_request) Successful in 18s
SDD Gate / Contract Validate (pull_request) Successful in 25s
SDD Gate / Constitution Impact (pull_request) Successful in 19s
CI / Unit & Component Tests (push) Successful in 4m33s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 5m19s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 26s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
CI / Unit & Component Tests (pull_request) Successful in 4m24s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 5m21s
The spine offset (0.5rem phone / 50% desktop) was hard-coded in both
TimelineView's .timeline-axis::before and YearBand's .year-node/.letter-dot,
kept in sync only by a comment. Declare --spine-x once on .timeline-axis
and have the markers consume it by inheritance, so a change to the spine
position moves the markers with it. Add a test that the year-node tracks
the token.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 13:51:46 +02:00
Marcel
0a235dc911 refactor(timeline): extract a shared GlyphLabel primitive
The aria-hidden glyph + sr-only label markup was hand-copied in LetterCard
and YearLetterStrip. Extract a small GlyphLabel component and use it at
both sites so the accessibility idiom has a single owner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 13:48:25 +02:00
Marcel
0bd6790b1f refactor(timeline): count timelineMeta totals in a single pass
Replace the flatMap intermediate array plus two filter passes with one
walk over the year bands and the undated bucket. Same counts, no
throwaway allocation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 12:19:40 +02:00
Marcel
84938e1bf3 refactor(timeline): render the WorldBand historical suffix once
The "· historisch" register was emitted in all three date branches, with
the dateless branch dropping the leading separator. Render the span pill
or date as a conditional prefix, then a single trailing "· historisch"
span — one render site, consistent separator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 12:17:39 +02:00
Marcel
398babe584 fix(timeline): pluralize the zeitstrahl meta-line counts
A count of one rendered "1 Briefe" / "1 Ereignisse". Add _singular
companion keys (de/en/es) and select them when the count is exactly one,
following the project's _singular/_plural convention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 12:15:11 +02:00
Marcel
9665c9c0fc fix(timeline): drop zero-count segments from the zeitstrahl meta line
A timeline with content in only one category rendered a misleading
"0 Briefe" / "0 Ereignisse" segment. Only push a count segment when its
count is greater than zero; the grouping label and (when present) the
range are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 12:10:37 +02:00
Marcel
f715f9ce9c fix(timeline): show EventPill provenance even when the event has no date
The provenance token (abgeleitet/kuratiert) was nested inside the
{#if dateLabel} block, so an undated or UNKNOWN-precision event — e.g.
one in the undated bucket — rendered no provenance at all. Compose the
subtitle as an optional "{date} · " prefix in front of the always-present
provenance instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 12:08:13 +02:00
Marcel
15836ea9ca refactor(timeline): drop the canvas outer border (REQ-001)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m1s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 5m1s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 23s
SDD Gate / RTM Check (pull_request) Successful in 16s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
SDD Gate / Contract Validate (pull_request) Successful in 23s
SDD Gate / Constitution Impact (pull_request) Successful in 18s
The page is already bg-canvas, so the frame's border was the only thing
making it visible; per review it reads cleaner without it. Keep the padded
bg-canvas surface; the timeline sits on the page without a frame line.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 11:39:17 +02:00
Marcel
8029bdec92 fix(timeline): keep the axis spine behind the in-flow content (REQ-006)
The absolutely-positioned spine ::before painted above the in-flow centered
content (density strips, event pills), drawing the line through them. Give
.timeline-axis a stacking context and the spine z-index:-1 so the line is
always background; cards, pills, strips, dots and badges ride on top.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 11:38:32 +02:00
Marcel
217508ddb2 fix(timeline): keep the year badge above its node marker (REQ-004)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m23s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 5m37s
CI / fail2ban Regex (pull_request) Successful in 53s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m10s
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 16s
The node marker carried a higher stacking order than the (non-positioned)
badge, so on the centered desktop axis the navy node painted over the white
year digits. Make the badge positioned with the higher z-index; the node now
sits behind the centered pill (which is itself the axis interruption) and
shows only to the badge's left on phone. Guarded by a z-index assertion.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 11:30:59 +02:00
Marcel
0a4f5c0a9d docs(rtm): mark #833 REQ-001..016 Done with their tests
All sixteen visual-fidelity requirements have a green test (142 timeline +
zeitstrahl + messages tests pass); record the implementation files and the
test that proves each.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 11:25:42 +02:00
Marcel
2ac4aa8f9c test(timeline): type-clean the zeitstrahl page spec data (REQ-001/002)
Pass the full PageData shape (layout auth fields + timeline) through a
pageData() helper so the route spec is svelte-check-clean; the component
still only reads data.timeline.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 11:16:07 +02:00
Marcel
bfe66569d7 feat(timeline): paint the axis with a three-stop gradient (REQ-006)
The spine now runs mint → navy → slate, matching the spec's life-thread,
using --palette-mint / --palette-navy / --c-tag-slate (no --palette-slate
token exists). Semantic tokens only — no raw hex.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 11:10:49 +02:00
Marcel
18934413bb feat(timeline): center the year badge and add spine markers (REQ-003/004/005)
The year badge now centers on the axis at ≥1024px and hugs the left spine
below that (sticky top:4rem preserved), with a navy node marker so it
visibly interrupts the spine. Each letter row gains a connector dot (white
fill, mint ring) on the spine: centered between card and axis on desktop,
on the left spine clear of the indented card on phone. Spine geometry is
commented to track TimelineView's spine so the markers can't silently desync.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 11:10:05 +02:00
Marcel
e4da28d795 feat(timeline): frame /zeitstrahl in a canvas with a meta line (REQ-001/002)
The timeline now sits inside a bordered, rounded bg-canvas sheet. Below the
heading a sub-line composes the year range, the letter and event counts
(from timelineMeta), and the static "Gruppierung: Datum" — joined by " · "
so the range drops out when there are no year bands and the whole line is
absent for an empty timeline. Semantic tokens only; AA-legible text-xs.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 11:00:54 +02:00
Marcel
a1e57ff8cf feat(timeline): derive header meta figures from the DTO (REQ-002)
A pure timelineMeta() returns the year range (first/last band, null when
there are no bands) and the letter/event totals across all year bands plus
the undated bucket — the single place these counts are computed.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:58:25 +02:00
Marcel
e0b096f12c feat(timeline): frame the undated bucket with a dashed border + count (REQ-012)
The "Ohne Datum" section now renders inside a dashed-bordered surface box
whose heading reads "Ohne Datum · {count}", matching the spec's .undated
treatment. The kind/type dispatch (events as pills/bands, letters as cards)
is unchanged; the section stays absent when there are no undated entries.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:55:37 +02:00
Marcel
6382efa65a feat(timeline): caption the density strip with ✉ + month axis (REQ-010/011)
The dense-year strip count now carries the ✉ glyph (aria-hidden + sr-only
"Brief"), and beneath the sparkline a "Monats-Dichte" caption sits between
the two endpoint month labels (Jan/Dez {year}) at the ≥10px micro-axis
floor, localized via the shared month formatter. The ≥44px keyboard-
focusable "Briefe anzeigen" expand toggle is preserved unchanged.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:52:55 +02:00
Marcel
144719720f feat(timeline): add the "· historisch" descriptor to world bands (REQ-009)
A HISTORICAL band's subtitle now carries the visible "historisch" register
inline as plain text: "{date} · historisch", or — for a RANGE — after the
existing 1914–1918 span pill (whose Zeitraum aria-label is unchanged). The
descriptor is a text node, never a second pill. Every WorldBand is
historical, so the suffix also trails an undated band on its own.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:49:19 +02:00
Marcel
fc67dfc3d5 feat(timeline): prefix letter titles with the ✉ glyph (REQ-008/016)
A present LetterCard title now reads "✉ {title}" with an aria-hidden glyph
and an sr-only "Brief" label rendered as sibling nodes — never interpolated
into the escaped user title, which keeps its own pre-line span for
multi-line OCR text. No title → no glyph, no label (the row still shows
sender → receiver and the date). An XSS regression pins the no-{@html}
contract: an HTML-bearing title renders verbatim as text.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:45:47 +02:00
Marcel
08d8896cd1 feat(timeline): show provenance token on the event pill (REQ-007)
The derived/curated pill subtitle now reads "{date} · abgeleitet" or
"{date} · kuratiert", keyed off entry.derived so a reader sees both the
date and whether the event was derived from Person data or curated. Only
the single provenance token ships; the spec sheet's "· persönlich" /
"· SEASON" annotations stay out (already covered by the ★ glyph + mint
border and not production UI).

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:42:33 +02:00
Marcel
808d6efa1a i18n(timeline): add zeitstrahl visual-fidelity strings (REQ-015)
Seven new timeline_* keys feeding the upcoming chrome: the header meta
line (grouping label + events count), the event-pill provenance token,
the ✉ sr-only label, the world-band "historisch" suffix, and the strip
density caption — present in de/en/es with matching key sets, pinned by a
new parity test.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:39:58 +02:00
Marcel
b372b90ec9 docs(rtm): add #833 zeitstrahl-visual-fidelity REQ-001..016 rows
Trace the visual-fidelity requirements end to end before implementation,
matching the timeline-feature convention of landing RTM rows in the first
commit of the branch. Status: Planned; flipped to Done as each task lands.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:36:09 +02:00
Marcel
bc02d22270 refactor(timeline): drop EventForm's redundant type state
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m13s
CI / OCR Service Tests (pull_request) Successful in 27s
CI / Backend Unit Tests (pull_request) Successful in 5m1s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
SDD Gate / RTM Check (pull_request) Successful in 15s
SDD Gate / Contract Validate (pull_request) Successful in 24s
SDD Gate / Constitution Impact (pull_request) Successful in 19s
CI / Unit & Component Tests (push) Successful in 4m33s
CI / OCR Service Tests (push) Successful in 27s
CI / Backend Unit Tests (push) Successful in 5m11s
CI / fail2ban Regex (push) Successful in 48s
CI / Semgrep Security Scan (push) Successful in 25s
CI / Compose Bucket Idempotency (push) Successful in 1m12s
The submitted type comes from EventTypeSelect's own hidden input; EventForm's
`type` $state was only read to seed `value=` and the onchange reassignment
had no downstream reader. Inline the seed expression and pass markDirty
directly, removing the second source of truth.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:13:17 +02:00
Marcel
23f6bc284d fix(timeline): validate RANGE end-date client-side with a field-level error
A RANGE event with a blank end date passed validateEventForm and reached
the backend, which 400s with a generic INVALID_DATE_RANGE mapped to "end
must not be before start" — wrong for a missing end date, and shown only as
a top-of-form alert. Validate it before the API call and surface a dedicated
event_editor_end_date_required message on the end-date field via a new
DatePrecisionField endDateError prop (defaults '', so the document form is
unchanged).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Addresses PR #832 review (Architect suggestion).

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

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

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

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

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

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

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

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

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

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

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

Addresses PR #832 review (#781).

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

Addresses PR #832 review (#781).

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

Addresses PR #832 review (#781).

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

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

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

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

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

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

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

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

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

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

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

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

Refs #781
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:29:46 +02:00
Marcel
d3f93c556a docs(rtm): REQ-024 now localized per locale, point at messages.spec pin
Some checks failed
CI / Semgrep Security Scan (push) Successful in 25s
CI / Compose Bucket Idempotency (push) Successful in 1m10s
nightly / deploy-staging (push) Successful in 5m8s
CI / Unit & Component Tests (pull_request) Successful in 3m42s
nightly / npm-audit (push) Failing after 17s
CI / OCR Service Tests (pull_request) Successful in 23s
Renovate / renovate (push) Failing after 33s
CI / Backend Unit Tests (pull_request) Successful in 4m43s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
SDD Gate / RTM Check (pull_request) Successful in 18s
SDD Gate / Contract Validate (pull_request) Successful in 29s
SDD Gate / Constitution Impact (pull_request) Successful in 21s
CI / Unit & Component Tests (push) Successful in 4m30s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 5m14s
CI / fail2ban Regex (push) Successful in 53s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 21:30:47 +02:00
Marcel
ce1b4c748e test(i18n): pin localized timeline layer/derived labels per locale
REQ-024 was updated (issue #779) to require localized sr-only/aria
labels instead of German-only. Pin the de/en/es values so they cannot
silently drift back to the German source strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 21:30:02 +02:00
Marcel
4a6fd770d7 fix(i18n): translate timeline sr-only labels in en/es locales
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 5m18s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 5m17s
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 1m8s
SDD Gate / RTM Check (pull_request) Successful in 15s
SDD Gate / Contract Validate (pull_request) Successful in 23s
SDD Gate / Constitution Impact (pull_request) Successful in 18s
timeline_layer_* and timeline_derived_* shipped German values in the
English and Spanish catalogs, so EN/ES screen-reader users heard German
for the world/family layer and birth/death/marriage cues. Translate them;
de.json stays canonical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 20:34:54 +02:00
Marcel
732651959e fix(timeline): render undated events as pills/bands, not letter cards
The undated bucket is assembled from all entries, so it can contain
events as well as letters. Rendering every undated entry with LetterCard
produced a dead /documents/undefined link and "Unknown -> Unknown" for
events. Dispatch on kind/type like YearBand does (WorldBand/EventPill/
LetterCard).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 20:32:32 +02:00
Marcel
7902f4e6ac refactor(timeline): extract entryKey helper from YearBand
Move the per-entry {#each} key logic into a shared entryKey.ts so the
undated bucket in TimelineView can reuse it. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 20:30:59 +02:00
Marcel
fee519b8a9 docs(timeline): document /zeitstrahl, lib/timeline, monthBuckets move; RTM #779
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 5m28s
CI / OCR Service Tests (pull_request) Successful in 26s
CI / Backend Unit Tests (pull_request) Successful in 6m25s
CI / fail2ban Regex (pull_request) Successful in 48s
CI / Semgrep Security Scan (pull_request) Successful in 25s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m10s
SDD Gate / RTM Check (pull_request) Successful in 18s
SDD Gate / Contract Validate (pull_request) Successful in 28s
SDD Gate / Constitution Impact (pull_request) Successful in 17s
Route tables (CLAUDE.md + frontend/CLAUDE.md), the document/timeline.ts ->
$lib/shared/utils/monthBuckets move (document + shared READMEs), GLOSSARY
Lebensweg entry, the c4 l3-frontend people-stories diagram, and the RTM rows
REQ-001..027 for feature zeitstrahl-global-view (#779), all marked Done.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 20:00:00 +02:00
Marcel
b501592156 docs(timeline): reword LetterCard comment so the REQ-021 @html grep is zero
Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:59:41 +02:00
Marcel
852fb71ee7 test(timeline): add /zeitstrahl E2E spec
Nav-link smoke + timeline-in-<main> (empty-or-populated), and the 320px
no-overflow guarantee on a timeline seeded with 25+char correspondent names
(REQ-005). Runs against the real stack via the seeded admin session.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:54:07 +02:00
Marcel
6f32299255 feat(timeline): add /zeitstrahl route, SSR load, and nav link
SSR-first load fetches GET /api/timeline via createApiClient (auth cookie
forwarded), no query params for the global view (REQ-001), returns { timeline }
with no client-side fetch (REQ-002); 401 -> /login, any other non-ok ->
error(status, getErrorMessage(...)), never raw JSON, no PII logged (REQ-022).
The page renders <TimelineView> under the layout's <main>. Adds the Zeitstrahl
nav link (desktop + mobile) and 'timeline' to the eslint routes boundary
allow-list so the route may import the domain.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:50:28 +02:00
Marcel
dbef0e1e60 fix(timeline): wrap long letter names/titles to avoid 320px overflow
break-words on sender/receiver/title so a 25+char correspondent name cannot
force horizontal overflow on a 320px phone (REQ-005).

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:44:23 +02:00
Marcel
588314f862 feat(timeline): add TimelineView orchestrator
Renders year bands in DTO order with interior empty-year runs folded into one
GapSpan (REQ-015), a single <ol> in chronological DOM order (REQ-006), the undated
bucket via {#if} (REQ-016), and a calm empty state (REQ-017). personId is a
declared seam (issue #10), undefined here, never passed to leaf cards (REQ-025).
Centered desktop spine / left phone spine via scoped CSS. Owns no <main>.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:44:05 +02:00
Marcel
f9ddcf0374 feat(timeline): add YearBand (section + sticky h2, cards vs strip)
One <section> per year with a sticky <h2> at top:4rem (REQ-006). Events render in
DTO order as pills/bands; letters render as individual cards while <= 12 (REQ-011)
or collapse to one density strip above that (REQ-012); DTO order is never re-sorted
(REQ-003). Letters carry an alternating data-side for the centered desktop axis
(REQ-004); single left column on phone (REQ-005). Derived-safe {#each} key.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:43:47 +02:00
Marcel
5bff428954 feat(timeline): add YearLetterStrip for dense years
Letter count + 12-month density sparkline + a >=44px keyboard-focusable expand
toggle that reveals that year's LetterCards (REQ-012). Sparkline values from the
shared monthHistogram.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:36:57 +02:00
Marcel
bea0e0d056 feat(timeline): add GapSpan for folded empty-year runs
A thin dashed span rendering '{from}–{to} · keine Einträge', collapsing to a
single year when the run has length 1 (REQ-015).

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:36:39 +02:00
Marcel
e75448ba14 feat(timeline): add WorldBand for HISTORICAL context bands
Full-width muted band; RANGE renders a span pill (1914–1918) with a Zeitraum
aria-label (REQ-009); a RANGE with no end degrades to the start year, no pill,
no crash (REQ-010). World glyph is a redundant non-color cue with sr-only label
(REQ-018); text uses text-ink-2 to hold AA in both themes (REQ-019).

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:36:21 +02:00
Marcel
b031f2736b feat(timeline): add EventPill for derived + curated event pills
Centered axis pill: derived life-events (* Geburt / † Tod / ⚭ Heirat) and curated
PERSONAL events (★, mint border) via getAccentConfig. Glyph wrapped aria-hidden +
sr-only label (REQ-018). Edit affordance only for a curated event with eventId,
never derived/null (REQ-008). REQ-007.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:36:03 +02:00
Marcel
e25001f7c9 feat(timeline): add LetterCard component
Single archive letter: sender → receiver (Unbekannt fallback for empty names,
REQ-014), title, precision date chip via timelineDateLabel (omitted when null,
REQ-013), linking to exactly /documents/{documentId} with no target (REQ-023).
44px touch target enforced inline + focus-visible ring (REQ-020). OCR/import
text via {...} escaping + whitespace-pre-line, no {@html} (REQ-021).

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:30:55 +02:00
Marcel
6a35e8510b feat(timeline): add eventCardConfig accent matrix + DTO test factories
getAccentConfig(entry) maps each EVENT to its glyph (* / † / ⚭ / ★ / ◍), German
redundant-cue label, and accent kind (REQ-007/008/018). test-factories build
TimelineEntryDTO/TimelineDTO mirroring the real wire shape for component specs.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:26:29 +02:00
Marcel
607112afc2 feat(shared): add Sparkline primitive for fixed-series density bars
A minimal presentational bar series (one bar per value, heights scaled to the
max, faint floor for empty buckets). Lives in shared so both the timeline
density strip and the document chart can use it. REQ-012 (supports).

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:26:11 +02:00
Marcel
4e119f098d feat(timeline): add timeline i18n keys (de/en/es)
14 Paraglide keys for the /zeitstrahl view: nav link, heading, empty/undated/
gap/unknown-person chrome, letters count, strip expand, range aria, and the
layer/derived labels. The layer (Weltgeschehen/Familie) and derived (Geburt/
Tod/Heirat) labels carry the German term across all locales by design
(documented MVP decision). REQ-024.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:25:43 +02:00
Marcel
f34d42a09f feat(timeline): add timelineDensity helper (isDense, monthHistogram)
isDense(count) thresholds dense year bands at >12 letters (REQ-012);
monthHistogram(letters, year) buckets a band's letters into exactly 12 month
buckets via the shared fillDensityGaps, counting each letter on its eventDate
anchor month and ignoring undated entries (REQ-027). Imports shared only.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:20:37 +02:00
Marcel
1dc3b91458 refactor(timeline): move pure month-bucket math to $lib/shared/utils/monthBuckets
Relocate the 10 pure helpers (monthBoundaryFrom/To, buildMonthSequence,
fillDensityGaps, clipBucketsToRange, aggregateToYears, selectionBoundaryFrom/To,
tickIndicesFor, formatTickLabel) and their unit tests out of document/timeline.ts
into a shared module so lib/timeline/ can consume them without importing
lib/document/. The /api/documents/density glue (buildDensityUrl, fetchDensity,
DensityState, DensityFilters) stays in document/timeline.ts. Re-point the three
density components and the density-filter spec at the shared module.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:18:50 +02:00
Marcel
1348255ae3 docs(timeline): update TimelineEntryDTO domain model table in CLAUDE.md
All checks were successful
CI / Unit & Component Tests (push) Successful in 5m25s
CI / OCR Service Tests (push) Successful in 25s
CI / Backend Unit Tests (push) Successful in 5m27s
CI / fail2ban Regex (push) Successful in 53s
CI / Semgrep Security Scan (push) Successful in 26s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
CI / Unit & Component Tests (pull_request) Successful in 6m30s
CI / OCR Service Tests (pull_request) Successful in 30s
CI / Backend Unit Tests (pull_request) Successful in 6m15s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 24s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
SDD Gate / RTM Check (pull_request) Successful in 15s
SDD Gate / Contract Validate (pull_request) Successful in 23s
SDD Gate / Constitution Impact (pull_request) Successful in 16s
The previous entry referenced fields (id: String, primaryPersonName,
relatedPersonName) from an earlier design that was superseded during
spec review. Replace with the actual 13-field record shape implemented
in PR #826.

Fixes: @markus stale CLAUDE.md entry on PR #826

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 17:24:01 +02:00
Marcel
590b00d2d7 fix(timeline): add @Transactional(readOnly=true) to TimelineService.assemble()
Without the annotation, Hibernate closes its sub-transaction after
eventRepository.findAll() returns, leaving TimelineEvent entities
detached. Accessing ev.getPersons() or doc.getReceivers() on those
detached entities throws LazyInitializationException in production
(constitution §1.6). @DataJpaTest and @Transactional test classes
masked the bug by keeping an outer session alive.

Fixes: @felix / @markus review blockers on PR #826

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 17:23:10 +02:00
Marcel
1de314f49b docs(timeline): RTM, CLAUDE.md, and C4 updates for #777 assembly endpoint
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m42s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 4m47s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / fail2ban Regex (pull_request) Successful in 47s
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 24s
SDD Gate / Constitution Impact (pull_request) Successful in 16s
- Add 20 REQ-NNN rows for issue #777 (all Done) to .specify/rtm.md
- Update CLAUDE.md timeline package description with TimelineService/TimelineController
- Extend l3-backend-timeline.puml with TimelineService/TimelineController components
  and their edges to PersonService and DocumentService

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 16:46:24 +02:00
Marcel
5017d17b11 chore(api): regenerate TypeScript types for GET /api/timeline
Adds TimelineDTO, TimelineYearDTO, TimelineEntryDTO with kind union
("EVENT"|"LETTER"), eventId, documentId, senderName, receiverName,
linkedPersonIds, derivedType fields.

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 16:43:24 +02:00
Marcel
3a174dd91b test(timeline): add integration tests for TimelineService + findByGeneration
Verifies PersonRepository.findByGeneration handles match, no-match (empty
list not NPE), and null-generation persons (excluded). Also confirms
TimelineService.assemble() returns a persisted curated event in the
correct year band against real Postgres via Testcontainers.

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 16:24:34 +02:00
Marcel
afd1f0b86b feat(timeline): add GET /api/timeline endpoint + 8-test controller suite
TimelineController exposes GET /api/timeline with @RequirePermission(READ_ALL)
and @Validated so @Min(0) on generation fires a 400. Delegates to
TimelineService.assemble(TimelineFilter). DomainException 404/400 propagate
via GlobalExceptionHandler (no extra mapping needed).

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 16:22:44 +02:00
Marcel
f08b09faeb feat(timeline): add TimelineService assembly + 24-test Mockito suite
Creates TimelineService.assemble(TimelineFilter): merges curated events
(TimelineEventRepository), derived life-events (assembleDerivedEvents()),
and archive letters (DocumentService) into a year-bucketed TimelineDTO.
WITHIN_BAND_ORDER Comparator tested standalone before assembly tests.
ArchUnit Rule 2 entry for ..timeline.. domain added in same commit.

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 16:20:54 +02:00
Marcel
de30f66a2d feat(timeline): add PersonService.getPersonsByGeneration + DocumentService.getAllForTimeline
PersonRepository.findByGeneration(Integer) — boxed to match nullable entity field.
DocumentRepository.findAllForTimeline() — Document.list entity-graph, single query.
Both services delegate with one-liner methods.

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 16:06:03 +02:00
Marcel
184fc9814a refactor(timeline): adapt TimelineEntryDTO to unified #777 shape
Replace the #776 DTO (primary/relatedPersonName + synthetic String id)
with the full #777 spec: kind, senderName, receiverName, eventId,
documentId, linkedPersonIds, title, eventDateEnd. Derived events now use
title=displayName, linkedPersonIds=[UUID...], eventId=null.

DerivedEventsAssemblyTest updated — all 16 tests pass.

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 16:03:00 +02:00
Marcel
6b593a7bc6 docs(timeline): add derived-event glossary entries and update C4 diagram
All checks were successful
CI / Compose Bucket Idempotency (pull_request) Successful in 1m11s
SDD Gate / RTM Check (pull_request) Successful in 19s
SDD Gate / Contract Validate (pull_request) Successful in 32s
SDD Gate / Constitution Impact (pull_request) Successful in 19s
CI / Unit & Component Tests (push) Successful in 3m33s
CI / OCR Service Tests (push) Successful in 25s
CI / Backend Unit Tests (push) Successful in 4m42s
CI / Semgrep Security Scan (push) Successful in 23s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
CI / fail2ban Regex (push) Successful in 49s
CI / Unit & Component Tests (pull_request) Successful in 6m13s
CI / OCR Service Tests (pull_request) Successful in 34s
CI / Backend Unit Tests (pull_request) Successful in 5m59s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 24s
Add GLOSSARY.md entries for derived event, DerivedEventType, derivedType,
and assembleDerivedEvents() to cover the vocabulary introduced by #776.
Update l3-backend-timeline.puml: remove stale "planned, #775" labels,
add Rel from TimelineEventService to personDomain for assembleDerivedEvents
batch-fetch calls, document the on-read strategy in the component notes.

Refs #776
Co-Authored-By: claude-sonnet-4-6 <noreply@anthropic.com>
2026-06-13 14:53:50 +02:00
Marcel
033001559d docs(timeline): update RTM and CLAUDE.md for issue #776
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 5m56s
CI / OCR Service Tests (pull_request) Successful in 30s
CI / Backend Unit Tests (pull_request) Successful in 6m26s
SDD Gate / RTM Check (pull_request) Has been cancelled
SDD Gate / Contract Validate (pull_request) Has been cancelled
SDD Gate / Constitution Impact (pull_request) Has been cancelled
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 27s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m13s
RTM: add REQ-001–REQ-016 rows with Done status, implementation files, and test IDs.
CLAUDE.md: expand timeline package entry with TimelineEntryDTO, DerivedEventType,
and assembleDerivedEvents(); add TimelineEntryDTO to domain model table.

Refs #776
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:37:19 +02:00
Marcel
c66d83cfc6 feat(timeline): implement assembleDerivedEvents() with TDD (REQ-001–REQ-016)
Adds RelationshipService dependency to TimelineEventService and implements:
- assembleDerivedEvents() — public @Transactional(readOnly=true) orchestrator
- buildBirthEvents() — Person.birthDate → BIRTH events with precision pass-through
- buildDeathEvents() — Person.deathDate → DEATH events with precision pass-through
- buildMarriageEvents() — SPOUSE_OF edges → MARRIAGE events, dedup on row id

Synthetic prefixed ids (birth:/death:/marriage:) are structurally non-UUID.
Null fromYear marriages are emitted with eventDate=null + precision=UNKNOWN (REQ-006).
Non-family-member persons excluded from birth/death; SPOUSE_OF edges always emit (REQ-013).

All 16 tests in DerivedEventsAssemblyTest pass.

Refs #776
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:35:50 +02:00
Marcel
7810ca7dd7 feat(relationship): add findAllSpouseEdges() for timeline assembly
Returns all SPOUSE_OF edges with JOIN FETCH on both person sides,
preventing N+1 in TimelineService.assembleDerivedEvents() (REQ-011).
Reuses existing findAllByRelationTypeIn query which already JOIN FETCHes.

Refs #776
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:30:30 +02:00
Marcel
4245b821b9 feat(timeline): add DerivedEventType enum and TimelineEntryDTO record
DerivedEventType: BIRTH / DEATH / MARRIAGE discriminator for derived events.
TimelineEntryDTO: unified String-id DTO for both curated and derived events;
id is String (not UUID) to accommodate synthetic prefixed ids (birth:/death:/marriage:).

Refs #776
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:29:36 +02:00
Marcel
663ffad49b docs(adr): add ADR-043 — derived person life-events on-read strategy (Proposed)
Covers three pre-implementation decisions for issue #776:
1. On-read assembly, never persisted (no migration)
2. Synthetic prefixed String ids (birth:/death:/marriage:)
3. assembleDerivedEvents() as the public cross-issue contract on TimelineService

Refs #776
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:28:48 +02:00
Marcel
b05990fffb docs(adr): renumber SDD adoption ADR 041 -> 042 (collision with renovate ADR)
All checks were successful
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 4m48s
SDD Gate / RTM Check (pull_request) Successful in 15s
SDD Gate / Contract Validate (pull_request) Successful in 24s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
SDD Gate / Constitution Impact (pull_request) Successful in 18s
CI / Unit & Component Tests (push) Successful in 4m58s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 5m51s
CI / fail2ban Regex (push) Successful in 48s
CI / Semgrep Security Scan (push) Successful in 23s
CI / Compose Bucket Idempotency (push) Successful in 1m9s
CI / Unit & Component Tests (pull_request) Successful in 3m36s
Two ADR-041 files landed on main in parallel (sdd-adoption and
renovate-runner-setup). Renames the SDD one to 042 and repoints its references
(SPEC_DRIVEN_DEVELOPMENT, constitution, .specify/adrs/README, sdd-gate.yml).
The renovate ADR keeps 041; its references are left untouched. Riding this PR
per request.

Refs #778
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 13:38:12 +02:00
107 changed files with 6863 additions and 621 deletions

View File

@@ -3,7 +3,7 @@ name: SDD Gate
# Spec-Driven Development quality gate. Runs on PRs.
#
# This project is ISSUE-ONLY: a feature's spec lives in its Gitea issue body, not a committed
# spec.md (see ADR-041). So CI cannot lint the spec text itself — instead it validates the SDD
# spec.md (see ADR-042). So CI cannot lint the spec text itself — instead it validates the SDD
# artifacts that DO live in git: the RTM, any committed OpenAPI contract, and the constitution.
#
# The first two jobs are NON-BLOCKING for now (continue-on-error) so the team can adopt the
@@ -11,7 +11,7 @@ name: SDD Gate
#
# TODO: flip rtm-check and contract-validate to BLOCKING (remove `continue-on-error: true`)
# once SDD adoption has settled — target: after the first 5 features have shipped through
# the workflow. Tracked in ADR-041.
# the workflow. Tracked in ADR-042.
on:
pull_request:

View File

@@ -10,7 +10,7 @@ This project already keeps a mature, permanent ADR archive at
next free `NNN` (verify against the directory on disk — parallel worktrees make
issue-body numbers stale). Template: [`../templates/adr.md`](../templates/adr.md).
- **The decision to adopt SDD itself** →
[`docs/adr/041-sdd-adoption.md`](../../docs/adr/041-sdd-adoption.md) (this is the
[`docs/adr/042-sdd-adoption.md`](../../docs/adr/042-sdd-adoption.md) (this is the
"ADR-000" the SDD scaffold calls for, numbered to fit the existing sequence).
- **Feature-local decisions** that are only meaningful within one in-flight feature →
beside that feature's spec, e.g.

View File

@@ -3,7 +3,7 @@
**Version:** v1.0.0
**Status:** Ratified
**Date:** 2026-06-13
**Adoption ADR:** [docs/adr/041-sdd-adoption.md](../docs/adr/041-sdd-adoption.md)
**Adoption ADR:** [docs/adr/042-sdd-adoption.md](../docs/adr/042-sdd-adoption.md)
> The non-negotiable rules of this project. Every spec, every PR, and every AI agent is
> bound by this document. Rules here are deliberately few and absolute — guidance and
@@ -73,7 +73,7 @@
When this constitution changes, the author MUST, in the same PR:
1. Bump the **Version** header per the semantic rule above and record the change in [docs/adr/041-sdd-adoption.md](../docs/adr/041-sdd-adoption.md)'s revision log (or a superseding ADR for a MAJOR change).
1. Bump the **Version** header per the semantic rule above and record the change in [docs/adr/042-sdd-adoption.md](../docs/adr/042-sdd-adoption.md)'s revision log (or a superseding ADR for a MAJOR change).
2. Re-read and reconcile every file that restates a rule changed here: `CLAUDE.md`, `COLLABORATING.md`, `CODESTYLE.md`, `CONTRIBUTING.md`, `.specify/AGENTS.md`, and the affected `.specify/personas/*.md` checklists.
3. Update any `.specify/templates/*` section that quotes a changed rule.
4. Run the `constitution-diff` CI job locally (or read its PR comment) and resolve every file it lists.

View File

@@ -43,3 +43,99 @@
| REQ-006 | `timeline` in coverage `include` (80% branch gate) | #778 | timeline-date-label | `frontend/vite.config.ts` | coverage `include` now lists `src/lib/timeline/**`; covered by all `dateLabel.spec.ts` cases | Done |
<!-- Append real features below this line, one row per REQ-NNN, with the real issue number. Keep the header row above. -->
| REQ-001 | family-member Person with birthDate → 1 BIRTH event, precision passed through | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents` | `DerivedEventsAssemblyTest#should_emit_one_geburt_for_person_with_birthdate`, `#should_pass_birth_precision_through_unchanged` | Done |
| REQ-002 | family-member Person with deathDate → 1 DEATH event | #776 | derive-person-life-events | `timeline/TimelineEventService#buildDeathEvents` | `DerivedEventsAssemblyTest#should_emit_one_tod_for_person_with_deathdate`, `#should_emit_one_tod_and_zero_geburt_for_person_with_deathdate_only` | Done |
| REQ-003 | null birthDate → 0 Geburt events | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents` | `DerivedEventsAssemblyTest#should_emit_zero_tod_when_person_has_birthdate_but_no_deathdate`, `#should_emit_one_tod_and_zero_geburt_for_person_with_deathdate_only` | Done |
| REQ-004 | null deathDate → 0 Tod events; no dates → 0 events | #776 | derive-person-life-events | `timeline/TimelineEventService#buildDeathEvents` | `DerivedEventsAssemblyTest#should_emit_no_events_for_person_with_neither_date` | Done |
| REQ-005 | SPOUSE_OF edge with fromYear → MARRIAGE event, eventDate={fromYear}-01-01, precision=YEAR | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_one_heirat_for_spouse_edge_with_fromYear` | Done |
| REQ-006 | SPOUSE_OF edge with null fromYear → MARRIAGE emitted, eventDate=null, precision=UNKNOWN | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_unknown_precision_heirat_when_fromYear_is_null` | Done |
| REQ-007 | exactly one MARRIAGE per SPOUSE_OF row (dedup on relationship id) | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_exactly_one_heirat_when_both_spouses_in_scope`, `#should_emit_two_heirat_for_person_married_to_two_partners` | Done |
| REQ-008 | synthetic prefixed ids (birth:/death:/marriage:), never parseable as UUID | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents,#buildDeathEvents,#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_mint_prefixed_synthetic_ids_never_uuid` | Done |
| REQ-009 | every derived event: derived=true, type=PERSONAL, non-null derivedType, non-UUID id | #776 | derive-person-life-events | `timeline/TimelineEventService` | structural invariants asserted inline in every event test | Done |
| REQ-010 | every derived event: non-null non-blank primaryPersonName; Heirat also non-null non-blank relatedPersonName | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents,#buildDeathEvents,#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_heirat_with_displayname_for_both_spouses` | Done |
| REQ-011 | exactly one call to findAllFamilyMembers() and one to findAllSpouseEdges() — no N+1 | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents`, `relationship/RelationshipService#findAllSpouseEdges` | test structure: only batch-fetch mocks used (no per-person stubs) | Done |
| REQ-012 | familyMember=false persons excluded from Geburt/Tod assembly | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents` (via PersonService.findAllFamilyMembers) | `DerivedEventsAssemblyTest#should_exclude_non_family_member_persons_from_derived_events` | Done |
| REQ-013 | SPOUSE_OF edge with one non-family-member spouse still emits 1 MARRIAGE event | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_heirat_when_one_spouse_is_not_family_member` | Done |
| REQ-014 | empty family-member list → empty result, no error | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents` | `DerivedEventsAssemblyTest#should_emit_zero_events_when_no_family_members` | Done |
| REQ-015 | assembleDerivedEvents() annotated @Transactional(readOnly=true) | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents` | code-review check (annotation visible in source) | Done |
| REQ-016 | no PII (names, dates) in log statements — only aggregate counts | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents` | log-audit: single log.debug with result.size() and persons.size() only | Done |
| REQ-001 | GET /api/timeline requires READ_ALL permission; 401 unauthenticated, 403 wrong permission | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_401_when_unauthenticated`, `#returns_403_when_authenticated_without_read_all`, `#returns_200_with_read_all_permission` | Done |
| REQ-002 | within-band sort: precision rank desc (DAY>MONTH>SEASON>YEAR>APPROX), then date asc, then title alpha, then id tiebreak | #777 | timeline-assembly | `timeline/TimelineService#WITHIN_BAND_ORDER` | `TimelineServiceTest#within_band_order_day_precision_sorts_before_year`, `#within_band_order_same_precision_and_date_sorts_alphabetically`, `#within_band_order_same_title_uses_document_id_as_tiebreak`, `#test5_day_precision_sorts_before_year_in_same_year_band`, `#test6_same_precision_same_date_sorted_alphabetically_by_title` | Done |
| REQ-003 | null eventDate OR UNKNOWN precision → undated bucket (never in a year band) | #777 | timeline-assembly | `timeline/TimelineService#bucketByYear` | `TimelineServiceTest#test3a_null_date_letter_goes_to_undated`, `#test3b_unknown_precision_letter_goes_to_undated` | Done |
| REQ-004 | RANGE events placed in start-year band only; null eventDateEnd does not crash; start year outside [fromYear,toYear] → excluded | #777 | timeline-assembly | `timeline/TimelineService#assembleCuratedEvents` | `TimelineServiceTest#test7a_range_event_placed_only_in_start_year_band`, `#test7b_range_event_with_null_eventDateEnd_does_not_crash`, `#test8_range_event_excluded_when_start_year_before_fromYear`, `#test15_range_event_start_year_equal_to_fromYear_is_included` | Done |
| REQ-005 | null sender and null senderText on a document → senderName="" in the TimelineEntryDTO | #777 | timeline-assembly | `timeline/TimelineService#toLetterEntry` | `TimelineServiceTest#test4_letter_with_null_sender_and_null_senderText_produces_empty_names` | Done |
| REQ-006 | personId filter: include document when personId is sender OR receiver | #777 | timeline-assembly | `timeline/TimelineService#fetchDocuments` | `TimelineServiceTest#test11_personId_scoping_deduplicates_letter_appearing_as_sender_and_receiver` | Done |
| REQ-007 | documents domain letters always included (no type filter applied to LETTER kind) | #777 | timeline-assembly | `timeline/TimelineService#fetchDocuments`, `#assemble` | `TimelineServiceTest#test9a_type_filter_does_not_exclude_letters_but_excludes_wrong_type_events`, `#test1_empty_archive_returns_empty_dto`, `#test2_one_year_letter_returns_one_year_band` | Done |
| REQ-008 | personId filter dedup: sender+receiver same person → document appears exactly once | #777 | timeline-assembly | `timeline/TimelineService#fetchDocuments` | `TimelineServiceTest#test11_personId_scoping_deduplicates_letter_appearing_as_sender_and_receiver` | Done |
| REQ-009 | type filter applies to events only; letters (LETTER kind) always pass | #777 | timeline-assembly | `timeline/TimelineService#assembleCuratedEvents`, `#assembleDerivedEventsLayer` | `TimelineServiceTest#test9a_type_filter_does_not_exclude_letters_but_excludes_wrong_type_events` | Done |
| REQ-010 | generation filter: PersonService.getPersonsByGeneration(N) used to build person-id set; filters all three layers | #777 | timeline-assembly | `timeline/TimelineService#assemble`, `person/PersonService#getPersonsByGeneration`, `person/PersonRepository#findByGeneration` | `TimelineServiceTest#test9b_generation_filter_includes_letter_when_sender_matches_generation`, `TimelineServiceIntegrationTest#findByGeneration_returns_matching_persons`, `#findByGeneration_returns_empty_list_not_npe_when_no_match`, `#findByGeneration_does_not_return_null_generation_persons` | Done |
| REQ-011 | fromYear/toYear inclusive year-range filter; single-year window (fromYear==toYear); one-sided filter (fromYear only) | #777 | timeline-assembly | `timeline/TimelineService#passesYearFilter` | `TimelineServiceTest#test9c_fromYear_toYear_inclusive_single_year_window`, `#test16_fromYear_without_toYear_returns_all_items_from_that_year_onwards` | Done |
| REQ-012 | combined filters AND logic — entry must pass all active filters | #777 | timeline-assembly | `timeline/TimelineService#assemble` | `TimelineServiceTest#test10_adversarial_and_logic_neither_event_passes_both_filters`, `#test12_personId_plus_generation_filter_returns_empty_when_generations_do_not_match` | Done |
| REQ-013 | empty archive (no events, no persons, no documents) → TimelineDTO { years=[], undated=[] } | #777 | timeline-assembly | `timeline/TimelineService#assemble` | `TimelineServiceTest#test1_empty_archive_returns_empty_dto` | Done |
| REQ-014 | unauthenticated request → 401 Unauthorized | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_401_when_unauthenticated` | Done |
| REQ-015 | authenticated without READ_ALL → 403 Forbidden | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_403_when_authenticated_without_read_all` | Done |
| REQ-016 | fromYear > toYear → 400 Bad Request | #777 | timeline-assembly | `timeline/TimelineService#assemble` | `TimelineServiceTest#fromYear_greater_than_toYear_throws_bad_request`, `TimelineControllerTest#returns_400_when_fromYear_greater_than_toYear` | Done |
| REQ-017 | generation < 0 → 400 Bad Request (@Min(0) on controller param) | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_400_when_generation_is_negative` | Done |
| REQ-018 | unknown EventType string → 400 Bad Request (Spring enum binding) | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_400_on_bad_type_value` | Done |
| REQ-019 | unknown personId → 404 Not Found | #777 | timeline-assembly | `timeline/TimelineService#assemble` | `TimelineControllerTest#returns_404_when_person_not_found` | Done |
| REQ-020 | null-generation persons excluded from generation-filter results | #777 | timeline-assembly | `person/PersonRepository#findByGeneration` | `TimelineServiceTest#test13_null_generation_sender_not_returned_by_generation_filter`, `TimelineServiceIntegrationTest#findByGeneration_does_not_return_null_generation_persons` | Done |
| REQ-001 | `/zeitstrahl` renders the global timeline for authenticated users, personId undefined | #779 | zeitstrahl-global-view | `frontend/src/routes/zeitstrahl/+page.server.ts`, `+page.svelte` | `zeitstrahl/page.server.test.ts#fetches GET /api/timeline and returns { timeline } on ok` | Done |
| REQ-002 | server-load fetches GET /api/timeline via createApiClient, returns { timeline }, no client fetch | #779 | zeitstrahl-global-view | `frontend/src/routes/zeitstrahl/+page.server.ts` | `zeitstrahl/page.server.test.ts#fetches GET /api/timeline and returns { timeline } on ok` | Done |
| REQ-003 | render bands + entries in DTO order, no client re-sort/re-bucket | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte`, `TimelineView.svelte` | `YearBand.svelte.spec.ts#renders entries in DTO order`, `TimelineView.svelte.spec.ts#renders the timeline as a single <ol>` | Done |
| REQ-004 | ≥1024px centered axis, letters alternating left/right | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte` (data-side CSS), `TimelineView.svelte` | `TimelineView.svelte.spec.ts#places consecutive letter cards on alternating sides`, `e2e/zeitstrahl.spec.ts` | Done |
| REQ-005 | <1024px single left axis, no overflow down to 320px | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte`, `LetterCard.svelte` | `e2e/zeitstrahl.spec.ts#no horizontal overflow at 320px with long correspondent names` | Done |
| REQ-006 | single `<ol>` chronological; each band a `<section>` with sticky `<h2>` at top:4rem | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte`, `YearBand.svelte` | `TimelineView.svelte.spec.ts#renders the timeline as a single <ol>`, `YearBand.svelte.spec.ts#sticky h2 at top:4rem` | Done |
| REQ-007 | derived entry → centered family pill with glyph + German derivedType label | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/EventPill.svelte`, `eventCardConfig.ts` | `EventPill.svelte.spec.ts#derived marriage/birth/death`, `eventCardConfig.spec.ts` | Done |
| REQ-008 | curated PERSONAL pill; edit affordance only when eventId != null | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/EventPill.svelte` | `EventPill.svelte.spec.ts#edit affordance for curated with eventId`, `#no edit affordance when eventId is null`, `#no edit affordance for a derived event` | Done |
| REQ-009 | HISTORICAL → full-width band once in eventDate year; RANGE span pill with Zeitraum aria-label | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/WorldBand.svelte` | `WorldBand.svelte.spec.ts#RANGE span pill 19141918 with a Zeitraum aria-label` | Done |
| REQ-010 | RANGE with null eventDateEnd → start-year label, no span pill, no crash | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/WorldBand.svelte` | `WorldBand.svelte.spec.ts#degrades a RANGE with no end to the start year` | Done |
| REQ-011 | band ≤12 letters → individual LetterCards | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte` | `YearBand.svelte.spec.ts#renders each letter as a card`, `TimelineView.svelte.spec.ts` | Done |
| REQ-012 | band >12 letters → single YearLetterStrip with count + 12-month sparkline + expand toggle | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearLetterStrip.svelte`, `timelineDensity.ts` | `YearLetterStrip.svelte.spec.ts`, `YearBand.svelte.spec.ts#renders a single strip`, `timelineDensity.spec.ts#isDense` | Done |
| REQ-013 | every dated entry renders date via timelineDateLabel; null → no chip | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#renders the precision date exactly`, `#renders no date chip when timelineDateLabel returns null` | Done |
| REQ-014 | empty senderName/receiverName → "Unbekannt" placeholder, never a bare arrow | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#shows "Unbekannt" for an empty sender`, `#empty receiver` | Done |
| REQ-015 | interior empty-year run → one folded GapSpan (single year if length 1) | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte`, `GapSpan.svelte` | `TimelineView.svelte.spec.ts#folds an interior run of empty years`, `#single empty interior year`, `GapSpan.svelte.spec.ts` | Done |
| REQ-016 | undated non-empty → final "Ohne Datum" section; empty → absent from DOM | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#renders an "Ohne Datum" section`, `#omits the "Ohne Datum" section when empty` | Done |
| REQ-017 | years + undated both empty → timeline.empty_state message | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#shows the empty state and no ol` | Done |
| REQ-018 | each layer carries a non-color redundant cue (glyph aria-hidden + sr-only label) | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/eventCardConfig.ts`, `EventPill.svelte`, `WorldBand.svelte` | `TimelineView.svelte.spec.ts#redundant non-color cue label`, `EventPill.svelte.spec.ts#wraps the glyph aria-hidden`, `WorldBand.svelte.spec.ts#world glyph` | Done |
| REQ-019 | every accent meets WCAG AA in light + dark; HISTORICAL label falls back to text-ink-2 | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/WorldBand.svelte` (text-ink-2) | manual pre-merge contrast check (both ratios recorded in PR) | Done |
| REQ-020 | LetterCard link ≥44px touch target + visible focus-visible ring | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#has a touch target of at least 44px` | Done |
| REQ-021 | OCR/import text rendered via `{...}` escaping; no `{@html}` in lib/timeline/ | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/*` | code review + `grep -r '@html' frontend/src/lib/timeline/` → zero; `LetterCard.svelte.spec.ts` | Done |
| REQ-022 | non-ok load → error(status, mapped); 401 → redirect('/login'); never raw JSON | #779 | zeitstrahl-global-view | `frontend/src/routes/zeitstrahl/+page.server.ts` | `zeitstrahl/page.server.test.ts#redirects to /login on 401`, `#404`, `#500`, `#403` | Done |
| REQ-023 | LetterCard href is exactly /documents/{documentId}, no target | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#links to exactly /documents/{documentId} with no target` | Done |
| REQ-024 | all user-facing strings via Paraglide keys (layer/derived labels localized per locale) | #779 | zeitstrahl-global-view | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#timeline layer/derived labels are localized per locale`, Paraglide compile | Done |
| 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 |
| REQ-001 | `/zeitstrahl` wraps the timeline in a `.tl-canvas` surface (rounded, bg-canvas, padding; outer border dropped in review — page is already bg-canvas) | #833 | zeitstrahl-visual-fidelity | `frontend/src/routes/zeitstrahl/+page.svelte` | `routes/zeitstrahl/page.svelte.spec.ts#wraps the timeline in a padded canvas surface, without an outer border` | Done |
| REQ-002 | meta sub-line: range + letter count + event count (years + undated) + "Gruppierung: Datum"; range/line omitted when empty | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/timelineMeta.ts`, `frontend/src/routes/zeitstrahl/+page.svelte` | `timelineMeta.spec.ts` (4 cases), `routes/zeitstrahl/page.svelte.spec.ts#renders the meta sub-line`, `#omits the range segment`, `#omits the entire sub-line` | Done |
| REQ-003 | year badge centered on axis ≥1024px, left spine <1024px; sticky top:4rem preserved | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/YearBand.svelte` | `YearBand.svelte.spec.ts#centers the year badge on the axis at desktop`, `#left-aligns the year badge at phone width`, `#keeps the sticky year heading at top:4rem` | Done |
| REQ-004 | year badge node marker on the spine, never overlapping the badge text (desktop + phone) | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/YearBand.svelte` | `YearBand.svelte.spec.ts#renders a year-badge node marker that clears the badge text on phone` | Done |
| REQ-005 | per-letter connector dot (white fill, mint ring) on the spine; phone column indented clear of card | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/YearBand.svelte` | `YearBand.svelte.spec.ts#renders one connector dot per letter row, each clearing its card on phone` | Done |
| REQ-006 | axis gradient 3-stop mint→navy→slate via `--palette-mint`/`--palette-navy`/`--c-tag-slate` | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#paints the axis with a three-stop gradient` (+ REQ-013 grep) | Done |
| REQ-007 | EventPill subtitle `{date} · {provenance}` keyed off `entry.derived` (abgeleitet/kuratiert) | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/EventPill.svelte` | `EventPill.svelte.spec.ts#appends the "abgeleitet" provenance`, `#appends the "kuratiert" provenance`, `#never shows persönlich/SEASON` | Done |
| REQ-008 | LetterCard title prefixed with `aria-hidden` ✉ + sr-only "Brief"; href intact | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#prefixes a present title with an aria-hidden ✉`, `#renders an HTML-bearing title verbatim` | Done |
| REQ-009 | WorldBand inline "· historisch" descriptor (non-RANGE & RANGE); RANGE span pill + aria-label intact | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/WorldBand.svelte` | `WorldBand.svelte.spec.ts#appends the inline "· historisch"`, `#follows the RANGE span pill with inline "· historisch"` | Done |
| REQ-010 | YearLetterStrip count ✉ + sr-only label + "Monats-Dichte" caption; expand toggle preserved | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/YearLetterStrip.svelte` | `YearLetterStrip.svelte.spec.ts#prefixes the count with an aria-hidden ✉`, `#keeps the expand toggle and its label` | Done |
| REQ-011 | YearLetterStrip exactly two endpoint month-axis labels (Jan/Dez {year}) ≥10px via formatTickLabel | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/YearLetterStrip.svelte` | `YearLetterStrip.svelte.spec.ts#renders exactly two endpoint month-axis labels` | Done |
| REQ-012 | undated "Ohne Datum · {count}" in a dashed frame; empty → absent; kind/type dispatch preserved | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#frames the undated section with a dashed border and shows the count`, `#omits the "Ohne Datum" section when empty` | Done |
| REQ-013 | all new styles use semantic tokens; corrected hex grep returns zero hits | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/*` | grep gate (REQ-013 form) → zero | Done |
| REQ-014 | no change to DTO order, density threshold (12), gap-folding, or ol/section/h2 structure | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/*` | existing timeline + `zeitstrahl/page.server.test.ts` suites stay green (142 tests) | Done |
| REQ-015 | new user-facing strings are Paraglide keys present in de/en/es with matching key sets | #833 | zeitstrahl-visual-fidelity | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl visual-fidelity keys are present in all locales`, `#identical key sets` | Done |
| REQ-016 | LetterCard with no title → no ✉, no sr-only "Brief"; sender→receiver + date still render | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#renders no ✉ glyph and no "Brief" label when the title is empty` | Done |

View File

@@ -99,7 +99,7 @@ backend/src/main/java/org/raddatz/familienarchiv/
│ └── relationship/ PersonRelationship sub-domain
├── security/ SecurityConfig, Permission, @RequirePermission, PermissionAspect
├── tag/ Tag domain
├── timeline/ Timeline (Zeitstrahl) domain — TimelineEvent, EventType, TimelineEventRepository
├── timeline/ Timeline (Zeitstrahl) domain — TimelineEvent, TimelineEventService, TimelineEntryDTO, DerivedEventType, EventType, TimelineEventRepository; TimelineEventService.assembleDerivedEvents() returns derived life-events (Geburt/Tod/Heirat) computed on read from Person/relationship data; TimelineService assembles year-bucketed TimelineDTO (curated events + derived events + archive letters); TimelineController exposes GET /api/timeline
└── user/ User domain — AppUser, UserGroup, UserService
```
@@ -121,6 +121,7 @@ backend/src/main/java/org/raddatz/familienarchiv/
| `Geschichte` | `geschichten` | `GeschichteType` (`STORY`/`JOURNEY`); ManyToMany `persons` (Person); OneToMany `items` (JourneyItem) |
| `JourneyItem` | `journey_items` | ManyToOne `geschichte` (Geschichte, ON DELETE CASCADE); ManyToOne `document` (Document, ON DELETE SET NULL); `position`, optional `note` |
| `TimelineEvent` | `timeline_events` | `EventType` (`PERSONAL`/`HISTORICAL`); ManyToMany `persons` (Person) + `documents` (Document), both join FKs ON DELETE CASCADE; `DatePrecision` date block; `@Version` + NOT NULL `createdBy`/`updatedBy` audit trail |
| `TimelineEntryDTO` | _(computed — no table)_ | Unified DTO for all timeline entries assembled by `TimelineService`; 13 fields: `kind` (`EVENT`\|`LETTER`), `precision` (raw `DatePrecision` enum), `derived` (boolean), `senderName` (non-null `String`, `""` = unknown), `receiverName` (non-null `String`, `""` = unknown), `eventDate`, `eventDateEnd`, `title`, `type` (`EventType`, null for LETTER), `eventId` (null for derived entries and letters), `documentId` (set for letters), `linkedPersonIds: List<UUID>`, `derivedType` (`DerivedEventType`, null for curated/letters); edit-affordance contract: `derived == true \|\| eventId == null` → no edit link |
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
@@ -206,6 +207,8 @@ frontend/src/routes/
├── aktivitaeten/ Unified activity feed (Chronik)
├── 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

@@ -3,7 +3,7 @@
How we turn a feature idea into merged, traceable code in this repo. SDD layers a uniform,
machine-readable front-end onto the workflow we already run (Gitea issues → branch/PR →
multi-persona review → red/green TDD). It does not replace any of that — see
[ADR-041](./docs/adr/041-sdd-adoption.md) for the why.
[ADR-042](./docs/adr/042-sdd-adoption.md) for the why.
- **The rules** live in [`.specify/constitution.md`](./.specify/constitution.md) (humans) and
[`.specify/AGENTS.md`](./.specify/AGENTS.md) (AI agents, every invocation).
@@ -179,7 +179,7 @@ issue body for you via the Gitea API.)
when a project-wide rule genuinely changes. Bump the semantic version (MAJOR = rule
removed/weakened, MINOR = rule added/tightened, PATCH = wording), run the §6 Sync Impact
review, and let the `constitution-diff` CI job list the files to reconcile. Record the bump
in ADR-041's revision log (or a superseding ADR for MAJOR).
in ADR-042's revision log (or a superseding ADR for MAJOR).
- **AGENTS.md** — keep it under 200 lines. It cross-references the constitution; it must never
duplicate or contradict it.
- **ADRs** — project-wide/irreversible decisions go in [`docs/adr/`](./docs/adr/) (next free

View File

@@ -56,6 +56,11 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
// Prüft effizient, ob ein Dateiname schon existiert (gibt true/false zurück)
boolean existsByOriginalFilename(String originalFilename);
// Bulk-fetch for global timeline path — single query with sender+receivers eager-loaded.
@EntityGraph("Document.list")
@Query("SELECT d FROM Document d")
List<Document> findAllForTimeline();
// lazy @BatchSize(50) fallback active; see ADR-022
@EntityGraph("Document.full")
List<Document> findBySenderId(UUID senderId);

View File

@@ -1051,6 +1051,10 @@ public class DocumentService {
return documentRepository.findDocumentsWithoutVersions();
}
public List<Document> getAllForTimeline() {
return documentRepository.findAllForTimeline();
}
public List<Document> getDocumentsBySender(UUID senderId) {
return documentRepository.findBySenderId(senderId);
}

View File

@@ -242,4 +242,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
)
""", nativeQuery = true)
void insertMissingReceiverReference(@Param("source") UUID source, @Param("target") UUID target);
// Boxed Integer — matches the nullable person.generation column (primitive int would reject null rows).
List<Person> findByGeneration(Integer generation);
}

View File

@@ -210,6 +210,10 @@ public class PersonService {
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
}
public List<Person> getPersonsByGeneration(Integer generation) {
return personRepository.findByGeneration(generation);
}
@Transactional
public Person setFamilyMember(UUID personId, boolean familyMember) {
Person person = getById(personId);

View File

@@ -86,6 +86,15 @@ public class RelationshipService {
return new NetworkDTO(nodes, edges);
}
/**
* Returns all {@code SPOUSE_OF} edges with both person sides JOIN FETCHed.
* Used by {@code TimelineService.assembleDerivedEvents()} to build Heirat events
* without per-edge N+1 queries.
*/
public List<PersonRelationship> findAllSpouseEdges() {
return relationshipRepository.findAllByRelationTypeIn(List.of(RelationType.SPOUSE_OF));
}
@Transactional
public RelationshipDTO addRelationship(UUID personId, CreateRelationshipRequest dto) {
if (personId.equals(dto.relatedPersonId())) {

View File

@@ -0,0 +1,8 @@
package org.raddatz.familienarchiv.timeline;
/** Discriminator for derived life-events assembled from Person / PersonRelationship data. */
public enum DerivedEventType {
BIRTH,
DEATH,
MARRIAGE
}

View File

@@ -0,0 +1,7 @@
package org.raddatz.familienarchiv.timeline;
/** Discriminates curated/derived events from archive letters in {@link TimelineEntryDTO}. */
public enum Kind {
EVENT,
LETTER
}

View File

@@ -0,0 +1,33 @@
package org.raddatz.familienarchiv.timeline;
import jakarta.validation.constraints.Min;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
@RestController
@RequestMapping("/api/timeline")
@Validated
@RequiredArgsConstructor
public class TimelineController {
private final TimelineService timelineService;
@GetMapping
@RequirePermission(Permission.READ_ALL)
public TimelineDTO getTimeline(
@RequestParam(required = false) UUID personId,
@RequestParam(required = false) @Min(0) Integer generation,
@RequestParam(required = false) EventType type,
@RequestParam(required = false) Integer fromYear,
@RequestParam(required = false) Integer toYear) {
return timelineService.assemble(new TimelineFilter(personId, generation, type, fromYear, toYear));
}
}

View File

@@ -0,0 +1,15 @@
package org.raddatz.familienarchiv.timeline;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
/**
* Assembled timeline response. Year bands are sorted ascending (oldest first).
* Undated entries have no usable date or {@code UNKNOWN} precision.
*/
public record TimelineDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<TimelineYearDTO> years,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<TimelineEntryDTO> undated
) {
}

View File

@@ -0,0 +1,42 @@
package org.raddatz.familienarchiv.timeline;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.document.DatePrecision;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/**
* Unified DTO for timeline entries — covers curated {@link TimelineEvent} rows, derived
* life-events ({@link DerivedEventType}), and archive letters (Documents).
*
* <p><b>Edit-affordance contract (for issue #7):</b> {@code derived == true || eventId == null}
* means no edit link should be rendered by the frontend.
*
* <p><b>Letter display fields:</b> {@code senderName} — {@code ""} means unknown/unlinked
* correspondent; frontend renders {@code 'Unbekannt'} fallback. Only populated for
* {@link Kind#LETTER} entries.
*
* <p><b>Type field:</b> {@code null} for {@link Kind#LETTER} entries; frontend must not render
* an event-type badge for letters.
*
* <p>Callers of {@code TimelineEventService.assembleDerivedEvents()} must independently enforce
* {@code READ_ALL} authorization before invoking that method (see ADR-043).
*/
public record TimelineEntryDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) Kind kind,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision precision,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean derived,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String senderName,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String receiverName,
LocalDate eventDate,
LocalDate eventDateEnd,
String title,
EventType type,
UUID eventId,
UUID documentId,
List<UUID> linkedPersonIds,
DerivedEventType derivedType
) {
}

View File

@@ -10,6 +10,8 @@ import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonService;
import org.raddatz.familienarchiv.person.relationship.PersonRelationship;
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
import org.raddatz.familienarchiv.timeline.TimelineEventView.DocumentRef;
import org.raddatz.familienarchiv.timeline.TimelineEventView.PersonView;
@@ -40,6 +42,7 @@ public class TimelineEventService {
private final TimelineEventRepository events;
private final PersonService personService;
private final DocumentService documentService;
private final RelationshipService relationshipService;
@Transactional
public TimelineEventView create(TimelineEventRequest request, UUID actorId) {
@@ -229,6 +232,83 @@ public class TimelineEventService {
return resolved;
}
// --- derived event assembly ---
/**
* Assembles derived life-events (Geburt/Tod/Heirat) from curated Person and
* PersonRelationship data. Computed on read, never persisted.
*
* <p>Derived events are computed, never persisted, and cannot be mutated via the events API
* (enforced in #5). Ids produced by this method are structurally non-UUID
* ({@code birth:*}, {@code death:*}, {@code marriage:*}) and MUST be rejected by any
* write endpoint — enforced and tested in #5. Callers outside the #5 endpoint must
* independently enforce {@code READ_ALL} authorization before invoking this method
* (see ADR-043).
*/
@Transactional(readOnly = true)
public List<TimelineEntryDTO> assembleDerivedEvents() {
List<Person> persons = personService.findAllFamilyMembers();
List<PersonRelationship> spouseEdges = relationshipService.findAllSpouseEdges();
List<TimelineEntryDTO> result = new ArrayList<>();
result.addAll(buildBirthEvents(persons));
result.addAll(buildDeathEvents(persons));
result.addAll(buildMarriageEvents(spouseEdges));
log.debug("Assembled {} derived events for {} persons", result.size(), persons.size());
return result;
}
private List<TimelineEntryDTO> buildBirthEvents(List<Person> persons) {
return persons.stream()
.filter(p -> p.getBirthDate() != null)
.map(p -> new TimelineEntryDTO(
Kind.EVENT, p.getBirthDatePrecision(), true, "", "",
p.getBirthDate(), null,
p.getDisplayName(), EventType.PERSONAL,
null, null, List.of(p.getId()), DerivedEventType.BIRTH))
.toList();
}
private List<TimelineEntryDTO> buildDeathEvents(List<Person> persons) {
return persons.stream()
.filter(p -> p.getDeathDate() != null)
.map(p -> new TimelineEntryDTO(
Kind.EVENT, p.getDeathDatePrecision(), true, "", "",
p.getDeathDate(), null,
p.getDisplayName(), EventType.PERSONAL,
null, null, List.of(p.getId()), DerivedEventType.DEATH))
.toList();
}
private List<TimelineEntryDTO> buildMarriageEvents(List<PersonRelationship> spouseEdges) {
// DB constraint unique_spouse_pair (V55) is the authoritative enforcement;
// in-memory dedup on relationship row id is a defensive assertion.
Set<UUID> seen = new HashSet<>();
List<TimelineEntryDTO> result = new ArrayList<>();
for (PersonRelationship r : spouseEdges) {
if (seen.add(r.getId())) {
// JOIN FETCH in findAllSpouseEdges() guarantees person/relatedPerson are loaded
LocalDate eventDate = r.getFromYear() != null
? LocalDate.of(r.getFromYear(), 1, 1)
: null;
DatePrecision precision = r.getFromYear() != null
? DatePrecision.YEAR
: DatePrecision.UNKNOWN;
String title = r.getPerson().getDisplayName()
+ " & " + r.getRelatedPerson().getDisplayName();
result.add(new TimelineEntryDTO(
Kind.EVENT, precision, true, "", "",
eventDate, null,
title, EventType.PERSONAL,
null, null,
List.of(r.getPerson().getId(), r.getRelatedPerson().getId()),
DerivedEventType.MARRIAGE));
}
}
return result;
}
// --- view assembly (explicit allow-list; never the raw entity) ---
private TimelineEventView toView(TimelineEvent event) {

View File

@@ -0,0 +1,16 @@
package org.raddatz.familienarchiv.timeline;
import java.util.UUID;
/**
* Immutable filter bag for {@link TimelineService#assemble(TimelineFilter)}.
* All fields are nullable — null means "no constraint on this dimension".
*/
public record TimelineFilter(
UUID personId,
Integer generation,
EventType type,
Integer fromYear,
Integer toYear
) {
}

View File

@@ -0,0 +1,268 @@
package org.raddatz.familienarchiv.timeline;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Assembles the family timeline from three sources — curated {@link TimelineEvent} rows,
* derived person life-events, and archive letters — into a year-bucketed {@link TimelineDTO}.
*
* <p>Cross-domain data is reached exclusively through domain services (PersonService,
* DocumentService). The only repository injected directly is {@link TimelineEventRepository}
* (same domain — constitution §1.3).
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class TimelineService {
/** Primary: precision rank descending (DAY first). Secondary: date ascending. Tertiary: title. Final: id. */
static final Comparator<TimelineEntryDTO> WITHIN_BAND_ORDER =
Comparator.comparingInt((TimelineEntryDTO e) -> precisionRank(e.precision())).reversed()
.thenComparing(e -> e.eventDate() != null ? e.eventDate() : java.time.LocalDate.MAX)
.thenComparing(e -> e.title() != null ? e.title() : "")
.thenComparing(e -> {
if (e.eventId() != null) return e.eventId().toString();
if (e.documentId() != null) return e.documentId().toString();
return "";
});
private final TimelineEventRepository eventRepository;
private final TimelineEventService timelineEventService;
private final DocumentService documentService;
private final PersonService personService;
/**
* Assembles the timeline for the given filter. All filters are ANDed.
* Throws {@link DomainException} (bad request) when fromYear &gt; toYear.
* Throws {@link DomainException} (not found) when personId refers to an unknown person.
*
* <p>{@code @Transactional(readOnly=true)} is required here — unlike simple scalar reads,
* this method accesses lazy collections ({@link TimelineEvent#getPersons()},
* {@link org.raddatz.familienarchiv.document.Document#getReceivers()}) after the
* repository sub-transaction closes. Without this annotation those accesses throw
* {@link org.hibernate.LazyInitializationException} in production (constitution §1.6).
*/
@Transactional(readOnly = true)
public TimelineDTO assemble(TimelineFilter filter) {
if (filter.fromYear() != null && filter.toYear() != null
&& filter.fromYear() > filter.toYear()) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
"toYear must not be before fromYear");
}
// Resolve generation person IDs once — used across all three layers
Set<UUID> genPersonIds = resolveGenerationPersonIds(filter.generation());
// ── curated events ───────────────────────────────────────────────────
List<TimelineEntryDTO> entries = new ArrayList<>();
for (TimelineEvent ev : eventRepository.findAll()) {
if (!passesTypeFilter(ev.getType(), filter.type())) continue;
if (!passesPersonFilter(ev.getPersons(), filter.personId())) continue;
if (!passesGenerationFilter(ev.getPersons(), genPersonIds)) continue;
if (!passesYearFilter(ev.getEventDate(), ev.getPrecision(), filter)) continue;
entries.add(mapEvent(ev));
}
// ── derived events ───────────────────────────────────────────────────
for (TimelineEntryDTO derived : timelineEventService.assembleDerivedEvents()) {
if (!passesTypeFilter(derived.type(), filter.type())) continue;
if (!passesDerivedPersonFilter(derived.linkedPersonIds(), filter.personId())) continue;
if (!passesDerivedGenerationFilter(derived.linkedPersonIds(), genPersonIds)) continue;
if (!passesYearFilter(derived.eventDate(), derived.precision(), filter)) continue;
entries.add(derived);
}
// ── letters ─────────────────────────────────────────────────────────
List<Document> docs = fetchDocuments(filter.personId());
for (Document doc : docs) {
if (!passesLetterGenerationFilter(doc, genPersonIds)) continue;
if (!passesYearFilter(doc.getDocumentDate(), doc.getMetaDatePrecision(), filter)) continue;
entries.add(mapDocument(doc));
}
return bucket(entries);
}
// ─── Bucketing ───────────────────────────────────────────────────────────
Map<Integer, List<TimelineEntryDTO>> bucketByYear(List<TimelineEntryDTO> entries) {
Map<Integer, List<TimelineEntryDTO>> map = new TreeMap<>();
for (TimelineEntryDTO e : entries) {
if (e.eventDate() == null || e.precision() == DatePrecision.UNKNOWN) continue;
map.computeIfAbsent(e.eventDate().getYear(), k -> new ArrayList<>()).add(e);
}
return map;
}
private TimelineDTO bucket(List<TimelineEntryDTO> entries) {
List<TimelineEntryDTO> undated = entries.stream()
.filter(e -> e.eventDate() == null || e.precision() == DatePrecision.UNKNOWN)
.sorted(WITHIN_BAND_ORDER)
.toList();
Map<Integer, List<TimelineEntryDTO>> byYear = bucketByYear(entries);
List<TimelineYearDTO> years = byYear.entrySet().stream()
.map(e -> new TimelineYearDTO(e.getKey(),
e.getValue().stream().sorted(WITHIN_BAND_ORDER).toList()))
.toList();
return new TimelineDTO(years, undated);
}
// ─── Document fetch (global vs personId path) ────────────────────────────
private List<Document> fetchDocuments(UUID personId) {
if (personId == null) {
return documentService.getAllForTimeline();
}
// personId path: validate existence, then union sender+receiver (dedup by id)
personService.getById(personId);
Map<UUID, Document> seen = new LinkedHashMap<>();
for (Document d : documentService.getDocumentsBySender(personId)) seen.put(d.getId(), d);
for (Document d : documentService.getDocumentsByReceiver(personId)) seen.putIfAbsent(d.getId(), d);
return new ArrayList<>(seen.values());
}
// ─── Filter predicates ───────────────────────────────────────────────────
private boolean passesTypeFilter(EventType entryType, EventType filterType) {
return filterType == null || filterType == entryType;
}
private boolean passesYearFilter(java.time.LocalDate date, DatePrecision precision, TimelineFilter filter) {
if (date == null || precision == DatePrecision.UNKNOWN) return true; // undated → always passes
int year = date.getYear();
if (filter.fromYear() != null && year < filter.fromYear()) return false;
if (filter.toYear() != null && year > filter.toYear()) return false;
return true;
}
private boolean passesPersonFilter(Set<Person> persons, UUID personId) {
if (personId == null) return true;
return persons != null && persons.stream().anyMatch(p -> personId.equals(p.getId()));
}
private boolean passesDerivedPersonFilter(List<UUID> linkedIds, UUID personId) {
if (personId == null) return true;
return linkedIds != null && linkedIds.contains(personId);
}
private Set<UUID> resolveGenerationPersonIds(Integer generation) {
if (generation == null) return null;
return personService.getPersonsByGeneration(generation).stream()
.map(Person::getId)
.collect(Collectors.toSet());
}
private boolean passesGenerationFilter(Set<Person> persons, Set<UUID> genPersonIds) {
if (genPersonIds == null) return true;
if (persons == null || persons.isEmpty()) return false;
return persons.stream().anyMatch(p -> genPersonIds.contains(p.getId()));
}
private boolean passesDerivedGenerationFilter(List<UUID> linkedIds, Set<UUID> genPersonIds) {
if (genPersonIds == null) return true;
if (linkedIds == null || linkedIds.isEmpty()) return false;
return linkedIds.stream().anyMatch(genPersonIds::contains);
}
private boolean passesLetterGenerationFilter(Document doc, Set<UUID> genPersonIds) {
if (genPersonIds == null) return true;
Person sender = doc.getSender();
if (sender != null && genPersonIds.contains(sender.getId())) return true;
Set<Person> receivers = doc.getReceivers();
if (receivers != null) {
return receivers.stream().anyMatch(r -> genPersonIds.contains(r.getId()));
}
return false;
}
// ─── Mapping ─────────────────────────────────────────────────────────────
private TimelineEntryDTO mapEvent(TimelineEvent ev) {
List<UUID> personIds = ev.getPersons() == null ? List.of()
: ev.getPersons().stream().map(Person::getId).toList();
return new TimelineEntryDTO(
Kind.EVENT,
ev.getPrecision(),
false,
"",
"",
ev.getEventDate(),
ev.getEventDateEnd(),
ev.getTitle(),
ev.getType(),
ev.getId(),
null,
personIds,
null
);
}
private TimelineEntryDTO mapDocument(Document doc) {
return new TimelineEntryDTO(
Kind.LETTER,
doc.getMetaDatePrecision(),
false,
resolveSenderName(doc),
resolveReceiverName(doc),
doc.getDocumentDate(),
null,
doc.getTitle(),
null,
null,
doc.getId(),
List.of(),
null
);
}
private String resolveSenderName(Document doc) {
if (doc.getSender() != null) return doc.getSender().getDisplayName();
String text = doc.getSenderText();
return (text != null && !text.isBlank()) ? text : "";
}
private String resolveReceiverName(Document doc) {
Set<Person> receivers = doc.getReceivers();
if (receivers != null && !receivers.isEmpty()) {
return receivers.stream().findFirst().map(Person::getDisplayName).orElse("");
}
String text = doc.getReceiverText();
return (text != null && !text.isBlank()) ? text : "";
}
private static int precisionRank(DatePrecision precision) {
if (precision == null) return 0;
return switch (precision) {
case DAY -> 5;
case MONTH -> 4;
case SEASON -> 3;
case YEAR -> 2;
case APPROX -> 1;
default -> 0;
};
}
}

View File

@@ -0,0 +1,12 @@
package org.raddatz.familienarchiv.timeline;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
/** One year's worth of timeline entries, sorted by {@link TimelineService#WITHIN_BAND_ORDER}. */
public record TimelineYearDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int year,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<TimelineEntryDTO> entries
) {
}

View File

@@ -2943,4 +2943,17 @@ class DocumentServiceTest {
assertThat(result.buckets()).isEmpty();
verify(documentRepository, org.mockito.Mockito.never()).findAll(any(Specification.class));
}
// --- getAllForTimeline ---
@Test
void getAllForTimeline_delegates_bulk_fetch_to_repository() {
Document doc = Document.builder().id(UUID.randomUUID()).title("Brief").build();
when(documentRepository.findAllForTimeline()).thenReturn(List.of(doc));
List<Document> result = documentService.getAllForTimeline();
assertThat(result).containsExactly(doc);
verify(documentRepository).findAllForTimeline();
}
}

View File

@@ -1105,4 +1105,25 @@ class PersonServiceTest {
assertThat(result.direct()).hasSize(1);
assertThat(result.partial()).isEmpty();
}
// --- getPersonsByGeneration ---
@Test
void getPersonsByGeneration_delegates_to_repository() {
Person p = Person.builder().id(UUID.randomUUID()).lastName("Müller").generation(2).build();
when(personRepository.findByGeneration(2)).thenReturn(List.of(p));
List<Person> result = personService.getPersonsByGeneration(2);
assertThat(result).containsExactly(p);
}
@Test
void getPersonsByGeneration_returns_emptyList_when_no_match() {
when(personRepository.findByGeneration(99)).thenReturn(List.of());
List<Person> result = personService.getPersonsByGeneration(99);
assertThat(result).isEmpty();
}
}

View File

@@ -100,6 +100,13 @@ class ArchitectureTest {
.and().resideInAPackage("..audit..")
.should().dependOnClassesThat(foreignJpaRepositoryFor("audit"));
@ArchTest
static final ArchRule services_only_access_own_domain_repositories_timeline =
noClasses()
.that().areAnnotatedWith(Service.class)
.and().resideInAPackage("..timeline..")
.should().dependOnClassesThat(foreignJpaRepositoryFor("timeline"));
// Rule 3: Infrastructure @Configuration classes must not end up scattered in domain packages.
// Keeps cross-cutting setup (security, async, DB, storage) in dedicated packages
// where it can be audited and reasoned about independently.

View File

@@ -0,0 +1,377 @@
package org.raddatz.familienarchiv.timeline;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonService;
import org.raddatz.familienarchiv.person.relationship.PersonRelationship;
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
import org.raddatz.familienarchiv.person.relationship.RelationType;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DerivedEventsAssemblyTest {
@Mock private TimelineEventRepository events;
@Mock private PersonService personService;
@Mock private DocumentService documentService;
@Mock private RelationshipService relationshipService;
@InjectMocks private TimelineEventService service;
// --- factory helpers ---
private Person makePerson(LocalDate birthDate, DatePrecision birthPrecision) {
return Person.builder()
.id(UUID.randomUUID())
.firstName("Anna")
.lastName("Müller")
.familyMember(true)
.birthDate(birthDate)
.birthDatePrecision(birthPrecision)
.build();
}
private Person makePersonWithDeath(LocalDate deathDate, DatePrecision deathPrecision) {
return Person.builder()
.id(UUID.randomUUID())
.firstName("Hans")
.lastName("Raddatz")
.familyMember(true)
.deathDate(deathDate)
.deathDatePrecision(deathPrecision)
.build();
}
private Person makePersonWithBoth(
LocalDate birthDate, DatePrecision birthPrecision,
LocalDate deathDate, DatePrecision deathPrecision) {
return Person.builder()
.id(UUID.randomUUID())
.firstName("Anna")
.lastName("Müller")
.familyMember(true)
.birthDate(birthDate)
.birthDatePrecision(birthPrecision)
.deathDate(deathDate)
.deathDatePrecision(deathPrecision)
.build();
}
private Person makeNonFamilyPerson(LocalDate birthDate, DatePrecision precision) {
return Person.builder()
.id(UUID.randomUUID())
.firstName("Anna")
.lastName("Müller")
.familyMember(false)
.birthDate(birthDate)
.birthDatePrecision(precision)
.build();
}
private PersonRelationship makeSpouseEdge(Person a, Person b, Integer fromYear) {
return PersonRelationship.builder()
.id(UUID.randomUUID())
.person(a)
.relatedPerson(b)
.relationType(RelationType.SPOUSE_OF)
.fromYear(fromYear)
.build();
}
// --- REQ-001: birth events ---
@Test
void should_emit_one_geburt_for_person_with_birthdate() {
Person anna = makePerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
assertThat(result).hasSize(1);
TimelineEntryDTO event = result.get(0);
assertThat(event.derived()).isTrue();
assertThat(event.type()).isEqualTo(EventType.PERSONAL);
assertThat(event.derivedType()).isEqualTo(DerivedEventType.BIRTH);
assertThat(event.eventDate()).isEqualTo(LocalDate.of(1901, 3, 12));
assertThat(event.precision()).isEqualTo(DatePrecision.DAY);
assertThat(event.title()).isEqualTo(anna.getDisplayName());
}
// --- REQ-003: null birthDate → no Geburt event ---
@Test
void should_emit_zero_tod_when_person_has_birthdate_but_no_deathdate() {
Person anna = makePerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
long todCount = result.stream()
.filter(e -> e.derivedType() == DerivedEventType.DEATH)
.count();
assertThat(todCount).isZero();
}
// --- REQ-004: null deathDate → no Tod event ---
@Test
void should_emit_no_events_for_person_with_neither_date() {
Person nobody = Person.builder()
.id(UUID.randomUUID())
.firstName("Hans")
.lastName("Raddatz")
.familyMember(true)
.build();
when(personService.findAllFamilyMembers()).thenReturn(List.of(nobody));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
assertThat(result).isEmpty();
}
// --- REQ-002: death events ---
@Test
void should_emit_one_tod_for_person_with_deathdate() {
Person hans = makePersonWithDeath(LocalDate.of(1965, 7, 4), DatePrecision.DAY);
when(personService.findAllFamilyMembers()).thenReturn(List.of(hans));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
assertThat(result).hasSize(1);
TimelineEntryDTO event = result.get(0);
assertThat(event.derived()).isTrue();
assertThat(event.type()).isEqualTo(EventType.PERSONAL);
assertThat(event.derivedType()).isEqualTo(DerivedEventType.DEATH);
assertThat(event.eventDate()).isEqualTo(LocalDate.of(1965, 7, 4));
assertThat(event.precision()).isEqualTo(DatePrecision.DAY);
assertThat(event.title()).isEqualTo(hans.getDisplayName());
}
// --- REQ-002 + REQ-003 combined ---
@Test
void should_emit_one_tod_and_zero_geburt_for_person_with_deathdate_only() {
Person hans = makePersonWithDeath(LocalDate.of(1965, 7, 4), DatePrecision.YEAR);
when(personService.findAllFamilyMembers()).thenReturn(List.of(hans));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
assertThat(result).hasSize(1);
assertThat(result.get(0).derivedType()).isEqualTo(DerivedEventType.DEATH);
}
// --- REQ-005: Heirat with fromYear ---
@Test
void should_emit_one_heirat_for_spouse_edge_with_fromYear() {
Person anna = makePerson(null, DatePrecision.UNKNOWN);
Person hans = makePersonWithDeath(null, DatePrecision.UNKNOWN);
PersonRelationship edge = makeSpouseEdge(anna, hans, 1930);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
List<TimelineEntryDTO> heiraten = result.stream()
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
.toList();
assertThat(heiraten).hasSize(1);
TimelineEntryDTO heirat = heiraten.get(0);
assertThat(heirat.derived()).isTrue();
assertThat(heirat.type()).isEqualTo(EventType.PERSONAL);
assertThat(heirat.derivedType()).isEqualTo(DerivedEventType.MARRIAGE);
assertThat(heirat.eventDate()).isEqualTo(LocalDate.of(1930, 1, 1));
assertThat(heirat.precision()).isEqualTo(DatePrecision.YEAR);
}
// --- REQ-006: Heirat with null fromYear → emitted with UNKNOWN precision ---
@Test
void should_emit_unknown_precision_heirat_when_fromYear_is_null() {
Person anna = makePerson(null, DatePrecision.UNKNOWN);
Person hans = makePersonWithDeath(null, DatePrecision.UNKNOWN);
PersonRelationship edge = makeSpouseEdge(anna, hans, null);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
List<TimelineEntryDTO> heiraten = result.stream()
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
.toList();
assertThat(heiraten).hasSize(1);
TimelineEntryDTO heirat = heiraten.get(0);
assertThat(heirat.eventDate()).isNull();
assertThat(heirat.precision()).isEqualTo(DatePrecision.UNKNOWN);
}
// --- REQ-007: exactly one Heirat per SPOUSE_OF edge (dedup) ---
@Test
void should_emit_exactly_one_heirat_when_both_spouses_in_scope() {
Person anna = makePerson(null, DatePrecision.UNKNOWN);
Person hans = makePerson(null, DatePrecision.UNKNOWN);
PersonRelationship edge = makeSpouseEdge(anna, hans, 1930);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
long heiratCount = result.stream()
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
.count();
assertThat(heiratCount).isEqualTo(1);
}
@Test
void should_emit_two_heirat_for_person_married_to_two_partners() {
Person anna = makePerson(null, DatePrecision.UNKNOWN);
Person hans = makePerson(null, DatePrecision.UNKNOWN);
Person karl = makePerson(null, DatePrecision.UNKNOWN);
PersonRelationship edge1 = makeSpouseEdge(anna, hans, 1930);
PersonRelationship edge2 = makeSpouseEdge(anna, karl, 1945);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans, karl));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge1, edge2));
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
long heiratCount = result.stream()
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
.count();
assertThat(heiratCount).isEqualTo(2);
}
// --- REQ-001 precision pass-through ---
@Test
void should_pass_birth_precision_through_unchanged() {
Person anna = makePerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
assertThat(result).hasSize(1);
assertThat(result.get(0).precision()).isEqualTo(DatePrecision.DAY);
}
// --- REQ-008: synthetic prefixed ids, never UUID ---
@Test
void should_mint_prefixed_synthetic_ids_never_uuid() {
Person anna = makePerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
assertThat(result).hasSize(1);
TimelineEntryDTO entry = result.get(0);
assertThat(entry.derived()).isTrue();
assertThat(entry.eventId()).isNull();
assertThat(entry.documentId()).isNull();
}
// --- REQ-010: display names on Heirat ---
@Test
void should_emit_heirat_with_displayname_for_both_spouses() {
Person anna = makePerson(null, DatePrecision.UNKNOWN);
Person hans = makePersonWithDeath(null, DatePrecision.UNKNOWN);
PersonRelationship edge = makeSpouseEdge(anna, hans, 1930);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
List<TimelineEntryDTO> heiraten = service.assembleDerivedEvents().stream()
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
.toList();
assertThat(heiraten).hasSize(1);
TimelineEntryDTO heirat = heiraten.get(0);
assertThat(heirat.title()).isNotNull().isNotBlank();
assertThat(heirat.linkedPersonIds()).hasSize(2);
}
// --- REQ-007 note: assumption/documentation test ---
@Test
void self_spouse_edge_invariant_is_enforced_by_db_constraint() {
// Assumption test — documents that the DB constraint prevents self-edges;
// the service does not guard this itself.
// The unique_spouse_pair index (V55) using LEAST/GREATEST is the authoritative guard.
// This test verifies that if an edge were somehow inserted (impossible in prod),
// the service would still produce one event (not zero or an exception).
Person anna = makePerson(null, DatePrecision.UNKNOWN);
PersonRelationship selfEdge = makeSpouseEdge(anna, anna, 1930);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(selfEdge));
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
long heiratCount = result.stream()
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
.count();
assertThat(heiratCount).isEqualTo(1);
}
// --- REQ-012: non-family-member persons excluded from Geburt/Tod ---
@Test
void should_exclude_non_family_member_persons_from_derived_events() {
Person nonMember = makeNonFamilyPerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
when(personService.findAllFamilyMembers()).thenReturn(List.of());
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
assertThat(result).isEmpty();
}
// --- REQ-013: Heirat emitted even when one spouse has familyMember=false ---
@Test
void should_emit_heirat_when_one_spouse_is_not_family_member() {
Person anna = makePerson(null, DatePrecision.UNKNOWN);
Person nonMember = makeNonFamilyPerson(null, DatePrecision.UNKNOWN);
PersonRelationship edge = makeSpouseEdge(anna, nonMember, 1930);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
long heiratCount = result.stream()
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
.count();
assertThat(heiratCount).isEqualTo(1);
}
// --- REQ-014: empty family-member list → empty result, no error ---
@Test
void should_emit_zero_events_when_no_family_members() {
when(personService.findAllFamilyMembers()).thenReturn(List.of());
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
assertThat(result).isEmpty();
}
}

View File

@@ -0,0 +1,139 @@
package org.raddatz.familienarchiv.timeline;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.security.SecurityConfig;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.UUID;
import static org.hamcrest.Matchers.is;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(TimelineController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class TimelineControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean TimelineService timelineService;
@MockitoBean UserService userService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
private static final TimelineDTO EMPTY = new TimelineDTO(List.of(), List.of());
@BeforeEach
void resolveDefaultPrincipal() {
when(userService.findByEmail("user"))
.thenReturn(AppUser.builder().id(UUID.randomUUID()).email("user").build());
}
// ─── Security ─────────────────────────────────────────────────────────────
@Test
void returns_401_when_unauthenticated() throws Exception {
// REQ-014
mockMvc.perform(get("/api/timeline"))
.andExpect(status().isUnauthorized());
}
@Test
@org.springframework.security.test.context.support.WithMockUser(authorities = "WRITE_ALL")
void returns_403_when_authenticated_without_read_all() throws Exception {
// REQ-015
mockMvc.perform(get("/api/timeline"))
.andExpect(status().isForbidden());
}
@Test
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
void returns_200_with_read_all_permission() throws Exception {
// REQ-001
when(timelineService.assemble(any())).thenReturn(EMPTY);
mockMvc.perform(get("/api/timeline"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.years").isArray())
.andExpect(jsonPath("$.undated").isArray());
}
// ─── Parameter binding ────────────────────────────────────────────────────
@Test
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
void valid_params_are_forwarded_to_service() throws Exception {
UUID personId = UUID.randomUUID();
when(timelineService.assemble(any())).thenReturn(EMPTY);
mockMvc.perform(get("/api/timeline")
.param("personId", personId.toString())
.param("generation", "2")
.param("type", "HISTORICAL")
.param("fromYear", "1914")
.param("toYear", "1918"))
.andExpect(status().isOk());
verify(timelineService).assemble(new TimelineFilter(personId, 2, EventType.HISTORICAL, 1914, 1918));
}
// ─── Validation errors ────────────────────────────────────────────────────
@Test
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
void returns_400_on_bad_type_value() throws Exception {
// REQ-018 — Spring enum binding rejects unknown value
mockMvc.perform(get("/api/timeline").param("type", "NOT_A_TYPE"))
.andExpect(status().isBadRequest());
}
@Test
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
void returns_400_when_fromYear_greater_than_toYear() throws Exception {
// REQ-016 — service throws bad request, controller propagates it
when(timelineService.assemble(any()))
.thenThrow(DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
"toYear must not be before fromYear"));
mockMvc.perform(get("/api/timeline")
.param("fromYear", "1920")
.param("toYear", "1914"))
.andExpect(status().isBadRequest());
}
@Test
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
void returns_400_when_generation_is_negative() throws Exception {
// REQ-017 — @Min(0) on generation parameter
mockMvc.perform(get("/api/timeline").param("generation", "-1"))
.andExpect(status().isBadRequest());
}
@Test
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
void returns_404_when_person_not_found() throws Exception {
// REQ-019
when(timelineService.assemble(any()))
.thenThrow(DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found"));
mockMvc.perform(get("/api/timeline").param("personId", UUID.randomUUID().toString()))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code", is("PERSON_NOT_FOUND")));
}
}

View File

@@ -0,0 +1,105 @@
package org.raddatz.familienarchiv.timeline;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.annotation.Transactional;
import software.amazon.awssdk.services.s3.S3Client;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for {@link TimelineService} and {@link PersonRepository#findByGeneration}
* against real Postgres. Verifies that assembled output reflects persisted curated events and
* that the generation query handles null-generation rows correctly.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
@Transactional
class TimelineServiceIntegrationTest {
@MockitoBean S3Client s3Client;
@Autowired TimelineService timelineService;
@Autowired TimelineEventRepository timelineEventRepository;
@Autowired PersonRepository personRepository;
@PersistenceContext EntityManager em;
// ─── PersonRepository.findByGeneration ────────────────────────────────────
@Test
void findByGeneration_returns_matching_persons() {
personRepository.save(Person.builder().lastName("Gen2A").generation(2).build());
personRepository.save(Person.builder().lastName("Gen2B").generation(2).build());
personRepository.save(Person.builder().lastName("Gen3").generation(3).build());
em.flush();
List<Person> result = personRepository.findByGeneration(2);
assertThat(result).extracting(Person::getLastName)
.containsExactlyInAnyOrder("Gen2A", "Gen2B");
}
@Test
void findByGeneration_returns_empty_list_not_npe_when_no_match() {
personRepository.save(Person.builder().lastName("Gen1").generation(1).build());
em.flush();
List<Person> result = personRepository.findByGeneration(99);
assertThat(result).isNotNull().isEmpty();
}
@Test
void findByGeneration_does_not_return_null_generation_persons() {
personRepository.save(Person.builder().lastName("NullGen").build()); // generation stays null
em.flush();
List<Person> result = personRepository.findByGeneration(1);
assertThat(result).extracting(Person::getLastName).doesNotContain("NullGen");
}
// ─── TimelineService.assemble end-to-end ─────────────────────────────────
@Test
void assemble_includes_persisted_curated_event_in_correct_year_band() {
UUID actorId = UUID.randomUUID();
TimelineEvent event = timelineEventRepository.save(TimelineEvent.builder()
.title("Sarajevo")
.type(EventType.HISTORICAL)
.eventDate(LocalDate.of(1914, 6, 28))
.precision(DatePrecision.DAY)
.createdBy(actorId)
.updatedBy(actorId)
.build());
em.flush();
em.clear();
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, null, null));
assertThat(result.years()).anySatisfy(y -> {
assertThat(y.year()).isEqualTo(1914);
assertThat(y.entries()).anySatisfy(e -> {
assertThat(e.title()).isEqualTo("Sarajevo");
assertThat(e.kind()).isEqualTo(Kind.EVENT);
assertThat(e.eventId()).isEqualTo(event.getId());
});
});
}
}

View File

@@ -0,0 +1,72 @@
package org.raddatz.familienarchiv.timeline;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.support.TransactionTemplate;
import software.amazon.awssdk.services.s3.S3Client;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
/**
* Verifies that {@link TimelineService#assemble} does not throw
* {@link org.hibernate.LazyInitializationException} when events have linked persons.
*
* <p>No class-level {@code @Transactional} — each test method runs without an outer
* transaction, matching production behaviour (controller has no {@code @Transactional}).
* If {@code assemble()} lacks {@code @Transactional(readOnly=true)}, accessing
* {@code ev.getPersons()} on detached entities throws LazyInitializationException.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class TimelineServiceLazyLoadTest {
@MockitoBean
S3Client s3Client;
@Autowired
TransactionTemplate transactionTemplate;
@Autowired
TimelineService timelineService;
@Autowired
TimelineEventRepository timelineEventRepository;
@Autowired
PersonRepository personRepository;
@Test
void assemble_does_not_throw_when_event_has_linked_persons() {
UUID actorId = UUID.randomUUID();
// Commit outside any test-managed transaction so entities are detached on return
transactionTemplate.execute(status -> {
Person person = personRepository.save(Person.builder().lastName("Müller").build());
timelineEventRepository.save(TimelineEvent.builder()
.title("Linked event")
.type(EventType.HISTORICAL)
.eventDate(LocalDate.of(1914, 7, 28))
.precision(DatePrecision.DAY)
.createdBy(actorId)
.updatedBy(actorId)
.persons(new HashSet<>(Set.of(person)))
.build());
return null;
});
assertDoesNotThrow(() -> timelineService.assemble(new TimelineFilter(null, null, null, null, null)));
}
}

View File

@@ -0,0 +1,452 @@
package org.raddatz.familienarchiv.timeline;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonService;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class TimelineServiceTest {
@Mock TimelineEventRepository eventRepository;
@Mock TimelineEventService timelineEventService;
@Mock DocumentService documentService;
@Mock PersonService personService;
@InjectMocks TimelineService timelineService;
// ─── WITHIN_BAND_ORDER standalone tests (REQ-002) ────────────────────────
@Test
void within_band_order_day_precision_sorts_before_year() {
var dayEntry = letter(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "B-brief");
var yearEntry = letter(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "A-brief");
var sorted = List.of(yearEntry, dayEntry).stream()
.sorted(TimelineService.WITHIN_BAND_ORDER)
.toList();
assertThat(sorted).containsExactly(dayEntry, yearEntry);
}
@Test
void within_band_order_same_precision_and_date_sorts_alphabetically() {
var entryZ = letter(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Zimmer");
var entryA = letter(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Adler");
var sorted = List.of(entryZ, entryA).stream()
.sorted(TimelineService.WITHIN_BAND_ORDER)
.toList();
assertThat(sorted).containsExactly(entryA, entryZ);
}
@Test
void within_band_order_same_title_uses_document_id_as_tiebreak() {
UUID id1 = UUID.fromString("00000000-0000-0000-0000-000000000001");
UUID id2 = UUID.fromString("00000000-0000-0000-0000-000000000002");
var e1 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id1, List.of(), null);
var e2 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id2, List.of(), null);
var sorted = List.of(e2, e1).stream()
.sorted(TimelineService.WITHIN_BAND_ORDER)
.toList();
assertThat(sorted.get(0).documentId()).isEqualTo(id1);
}
// ─── Assembly tests (issue-spec order) ──────────────────────────────────
@Test
void test1_empty_archive_returns_empty_dto() {
// REQ-013, REQ-007
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of());
TimelineDTO result = timelineService.assemble(noFilters());
assertThat(result.years()).isEmpty();
assertThat(result.undated()).isEmpty();
}
@Test
void test2_one_year_letter_returns_one_year_band() {
// REQ-007
var doc = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR);
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
TimelineDTO result = timelineService.assemble(noFilters());
assertThat(result.years()).hasSize(1);
assertThat(result.years().get(0).year()).isEqualTo(1914);
assertThat(result.years().get(0).entries()).hasSize(1);
assertThat(result.years().get(0).entries().get(0).kind()).isEqualTo(Kind.LETTER);
assertThat(result.undated()).isEmpty();
}
@Test
void test3a_null_date_letter_goes_to_undated() {
// REQ-003
var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
.metaDatePrecision(DatePrecision.YEAR).build(); // documentDate stays null
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
TimelineDTO result = timelineService.assemble(noFilters());
assertThat(result.years()).isEmpty();
assertThat(result.undated()).hasSize(1);
}
@Test
void test3b_unknown_precision_letter_goes_to_undated() {
// REQ-003
var doc = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.UNKNOWN);
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
TimelineDTO result = timelineService.assemble(noFilters());
assertThat(result.years()).isEmpty();
assertThat(result.undated()).hasSize(1);
}
@Test
void test4_letter_with_null_sender_and_null_senderText_produces_empty_names() {
// REQ-005
var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
.metaDatePrecision(DatePrecision.YEAR)
.documentDate(LocalDate.of(1914, 1, 1))
.build(); // no sender, no senderText, no receivers, no receiverText
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
TimelineDTO result = timelineService.assemble(noFilters());
var entry = result.years().get(0).entries().get(0);
assertThat(entry.senderName()).isEqualTo("");
assertThat(entry.receiverName()).isEqualTo("");
}
@Test
void test5_day_precision_sorts_before_year_in_same_year_band() {
// REQ-002
var dayLetter = docWithDate(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "B-brief");
var yearLetter = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "A-brief");
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(yearLetter, dayLetter));
TimelineDTO result = timelineService.assemble(noFilters());
var entries = result.years().get(0).entries();
assertThat(entries).hasSize(2);
assertThat(entries.get(0).precision()).isEqualTo(DatePrecision.DAY);
assertThat(entries.get(1).precision()).isEqualTo(DatePrecision.YEAR);
}
@Test
void test6_same_precision_same_date_sorted_alphabetically_by_title() {
// REQ-002
var letterZ = docWithDate(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Zimmer");
var letterA = docWithDate(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Adler");
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(letterZ, letterA));
TimelineDTO result = timelineService.assemble(noFilters());
var entries = result.years().get(0).entries();
assertThat(entries).hasSize(2);
assertThat(entries.get(0).title()).isEqualTo("Adler");
assertThat(entries.get(1).title()).isEqualTo("Zimmer");
}
@Test
void test7a_range_event_placed_only_in_start_year_band() {
// REQ-004
var rangeEvent = event("WW1", EventType.HISTORICAL,
LocalDate.of(1914, 7, 28), DatePrecision.RANGE, LocalDate.of(1918, 11, 11));
when(eventRepository.findAll()).thenReturn(List.of(rangeEvent));
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of());
TimelineDTO result = timelineService.assemble(noFilters());
assertThat(result.years()).hasSize(1);
assertThat(result.years().get(0).year()).isEqualTo(1914);
assertThat(result.years().stream().noneMatch(y -> y.year() == 1918)).isTrue();
}
@Test
void test7b_range_event_with_null_eventDateEnd_does_not_crash() {
// REQ-004
var rangeEvent = event("Offener Zeitraum", EventType.PERSONAL,
LocalDate.of(1914, 1, 1), DatePrecision.RANGE, null);
when(eventRepository.findAll()).thenReturn(List.of(rangeEvent));
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of());
assertThatCode(() -> timelineService.assemble(noFilters())).doesNotThrowAnyException();
}
@Test
void test8_range_event_excluded_when_start_year_before_fromYear() {
// REQ-004
var rangeEvent = event("WW1", EventType.HISTORICAL,
LocalDate.of(1914, 7, 28), DatePrecision.RANGE, LocalDate.of(1918, 11, 11));
when(eventRepository.findAll()).thenReturn(List.of(rangeEvent));
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of());
// fromYear=1915 → start year 1914 is outside → excluded
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1915, null));
assertThat(result.years()).isEmpty();
}
@Test
void test9a_type_filter_does_not_exclude_letters_but_excludes_wrong_type_events() {
// REQ-009
var doc = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "Brief");
var historicalEvent = event("Sarajevo", EventType.HISTORICAL,
LocalDate.of(1914, 6, 28), DatePrecision.DAY, null);
var personalEvent = event("Geburt", EventType.PERSONAL,
LocalDate.of(1914, 8, 1), DatePrecision.DAY, null);
when(eventRepository.findAll()).thenReturn(List.of(historicalEvent, personalEvent));
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
// filter: only HISTORICAL events
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, EventType.HISTORICAL, null, null));
long letters = result.years().stream().flatMap(y -> y.entries().stream())
.filter(e -> e.kind() == Kind.LETTER).count();
long personalEvents = result.years().stream().flatMap(y -> y.entries().stream())
.filter(e -> e.kind() == Kind.EVENT && e.type() == EventType.PERSONAL).count();
assertThat(letters).isEqualTo(1);
assertThat(personalEvents).isEqualTo(0);
}
@Test
void test9b_generation_filter_includes_letter_when_sender_matches_generation() {
// REQ-010
var sender = Person.builder().id(UUID.randomUUID())
.lastName("Mustermann").firstName("Max").generation(2).build();
var included = Document.builder().id(UUID.randomUUID()).title("Treffer")
.metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1))
.sender(sender).build();
var excluded = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "Kein Treffer"); // no sender
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(included, excluded));
when(personService.getPersonsByGeneration(2)).thenReturn(List.of(sender));
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, 2, null, null, null));
assertThat(result.years()).hasSize(1);
assertThat(result.years().get(0).entries()).hasSize(1);
assertThat(result.years().get(0).entries().get(0).title()).isEqualTo("Treffer");
}
@Test
void test9c_fromYear_toYear_inclusive_single_year_window() {
// REQ-011
var before = docWithDate(LocalDate.of(1913, 12, 31), DatePrecision.YEAR, "Vorher");
var inYear = docWithDate(LocalDate.of(1914, 6, 1), DatePrecision.MONTH, "Im Jahr");
var after = docWithDate(LocalDate.of(1915, 1, 1), DatePrecision.YEAR, "Nachher");
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(before, inYear, after));
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1914, 1914));
assertThat(result.years()).hasSize(1);
assertThat(result.years().get(0).year()).isEqualTo(1914);
assertThat(result.years().get(0).entries().get(0).title()).isEqualTo("Im Jahr");
}
@Test
void test10_adversarial_and_logic_neither_event_passes_both_filters() {
// REQ-012 — type AND year must both pass
var wrongType = event("Personal", EventType.PERSONAL,
LocalDate.of(1914, 1, 1), DatePrecision.YEAR, null);
var wrongYear = event("Historical outside", EventType.HISTORICAL,
LocalDate.of(1920, 1, 1), DatePrecision.YEAR, null);
when(eventRepository.findAll()).thenReturn(List.of(wrongType, wrongYear));
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of());
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, EventType.HISTORICAL, 1914, 1914));
assertThat(result.years()).isEmpty();
assertThat(result.undated()).isEmpty();
}
@Test
void test11_personId_scoping_deduplicates_letter_appearing_as_sender_and_receiver() {
// REQ-008
UUID personId = UUID.randomUUID();
var person = Person.builder().id(personId).lastName("Mustermann").build();
var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
.metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1))
.sender(person)
.receivers(Set.of(person))
.build();
when(personService.getById(personId)).thenReturn(person);
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getDocumentsBySender(personId)).thenReturn(List.of(doc));
when(documentService.getDocumentsByReceiver(personId)).thenReturn(List.of(doc));
TimelineDTO result = timelineService.assemble(new TimelineFilter(personId, null, null, null, null));
long total = result.years().stream().mapToLong(y -> y.entries().size()).sum()
+ result.undated().size();
assertThat(total).isEqualTo(1);
}
@Test
void test12_personId_plus_generation_filter_returns_empty_when_generations_do_not_match() {
// REQ-012
UUID personId = UUID.randomUUID();
var person = Person.builder().id(personId).lastName("Mustermann").generation(1).build();
var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
.metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1))
.sender(person).build();
var gen2person = Person.builder().id(UUID.randomUUID()).lastName("Schmidt").generation(2).build();
when(personService.getById(personId)).thenReturn(person);
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getDocumentsBySender(personId)).thenReturn(List.of(doc));
when(documentService.getDocumentsByReceiver(personId)).thenReturn(List.of());
when(personService.getPersonsByGeneration(2)).thenReturn(List.of(gen2person)); // person not in gen2
TimelineDTO result = timelineService.assemble(new TimelineFilter(personId, 2, null, null, null));
assertThat(result.years()).isEmpty();
assertThat(result.undated()).isEmpty();
}
@Test
void test13_null_generation_sender_not_returned_by_generation_filter() {
// REQ-020 — both sender and receiver have null generation → excluded
var nullGenSender = Person.builder().id(UUID.randomUUID()).lastName("Sender").build(); // generation = null
var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
.metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1))
.sender(nullGenSender).build();
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
when(personService.getPersonsByGeneration(1)).thenReturn(List.of()); // nobody in generation 1
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, 1, null, null, null));
assertThat(result.years()).isEmpty();
assertThat(result.undated()).isEmpty();
}
@Test
void test14_year_band_contains_only_event_when_no_letters_in_that_year() {
var ev = event("Ausbruch", EventType.HISTORICAL, LocalDate.of(1914, 7, 28), DatePrecision.DAY, null);
when(eventRepository.findAll()).thenReturn(List.of(ev));
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of());
TimelineDTO result = timelineService.assemble(noFilters());
assertThat(result.years()).hasSize(1);
assertThat(result.years().get(0).entries()).hasSize(1);
assertThat(result.years().get(0).entries().get(0).kind()).isEqualTo(Kind.EVENT);
}
@Test
void test15_range_event_start_year_equal_to_fromYear_is_included() {
// REQ-004 — inclusive lower bound
var rangeEvent = event("WW1", EventType.HISTORICAL,
LocalDate.of(1914, 7, 28), DatePrecision.RANGE, LocalDate.of(1918, 11, 11));
when(eventRepository.findAll()).thenReturn(List.of(rangeEvent));
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of());
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1914, null));
assertThat(result.years()).hasSize(1);
assertThat(result.years().get(0).year()).isEqualTo(1914);
}
@Test
void test16_fromYear_without_toYear_returns_all_items_from_that_year_onwards() {
// REQ-011
var old = docWithDate(LocalDate.of(1919, 12, 31), DatePrecision.YEAR, "Alt");
var first = docWithDate(LocalDate.of(1920, 1, 1), DatePrecision.YEAR, "Erst");
var newer = docWithDate(LocalDate.of(1921, 6, 1), DatePrecision.YEAR, "Newer");
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(old, first, newer));
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1920, null));
assertThat(result.years()).hasSize(2);
assertThat(result.years().stream().noneMatch(y -> y.year() == 1919)).isTrue();
}
@Test
void fromYear_greater_than_toYear_throws_bad_request() {
// REQ-016 (service-layer guard)
assertThatThrownBy(() -> timelineService.assemble(new TimelineFilter(null, null, null, 1920, 1914)))
.isInstanceOf(DomainException.class);
}
// ─── Helpers ─────────────────────────────────────────────────────────────
private static TimelineFilter noFilters() {
return new TimelineFilter(null, null, null, null, null);
}
private static TimelineEntryDTO letter(LocalDate date, DatePrecision precision, String title) {
return new TimelineEntryDTO(Kind.LETTER, precision, false, "", "",
date, null, title, null, null, UUID.randomUUID(), List.of(), null);
}
private static Document docWithDate(LocalDate date, DatePrecision precision) {
return Document.builder().id(UUID.randomUUID()).title("Brief")
.metaDatePrecision(precision).documentDate(date).build();
}
private static Document docWithDate(LocalDate date, DatePrecision precision, String title) {
return Document.builder().id(UUID.randomUUID()).title(title)
.metaDatePrecision(precision).documentDate(date).build();
}
private static TimelineEvent event(String title, EventType type, LocalDate date,
DatePrecision precision, LocalDate endDate) {
return TimelineEvent.builder().id(UUID.randomUUID())
.title(title).type(type)
.eventDate(date).precision(precision).eventDateEnd(endDate)
.build();
}
}

View File

@@ -168,7 +168,18 @@ _Not to be confused with a document item's optional note_ — a document item's
**EventType** (`EventType`) `[user-facing]` — the kind of a `TimelineEvent`: `PERSONAL` (a family event, rendered with the family accent) or `HISTORICAL` (world/historical context, rendered with a muted world accent). The string value names are a stable frontend styling contract — renaming requires a coordinated frontend change (ADR-040).
**Zeitstrahl** `[user-facing]` — the family timeline view, rendering curated `TimelineEvent`s (and, in later issues, derived life-events) chronologically. The milestone home of the `timeline` domain.
**Zeitstrahl** `[user-facing]` — the family timeline view, rendering curated `TimelineEvent`s and derived life-events chronologically. The milestone home of the `timeline` domain.
**Lebensweg** `[user-facing]` — the per-person variant of the *Zeitstrahl*: the same `TimelineView` component, scoped to a single person via a `personId` prop, rendering that person's life-events, events, and letters as a left-anchored rail. The global Zeitstrahl is the `personId`-undefined case of the same component (issue #10 wires the per-person rail; the prop seam ships with the global view).
**derived event** — a timeline entry computed on-read from curated `Person` or `PersonRelationship` data, never persisted. Carried as a `TimelineEntryDTO` with `derived=true` and a non-null `DerivedEventType`. Three subtypes: Geburt (birth, from `Person.birthDate`), Tod (death, from `Person.deathDate`), Heirat (marriage, from a `SPOUSE_OF` `PersonRelationship` edge). Callers of `assembleDerivedEvents()` are responsible for enforcing `READ_ALL` authorization before invoking it (ADR-043).
_Not to be confused with a `TimelineEvent`_ — a `TimelineEvent` is a curated record authored by a human and stored in `timeline_events`; a derived event is computed on-the-fly and never written to the database.
**DerivedEventType** (`DerivedEventType`) `[internal]` — enum with three values: `BIRTH`, `DEATH`, `MARRIAGE`. Carried on `TimelineEntryDTO.derivedType`; `null` on curated-event entries exposed through the same DTO.
**derivedType** (`TimelineEntryDTO.derivedType`) `[internal]` — the `DerivedEventType` field distinguishing a derived Geburt/Tod/Heirat event from a curated one. Always non-null on derived events; `null` on curated events.
**assembleDerivedEvents()** (`TimelineEventService.assembleDerivedEvents()`) `[internal]` — the public `@Transactional(readOnly=true)` method that computes all derived events in one call: one batch fetch of family-member `Person`s via `PersonService.findAllFamilyMembers()` and one batch fetch of `SPOUSE_OF` edges via `RelationshipService.findAllSpouseEdges()`. Result is never persisted. Synthetic ids produced by this method (`birth:{uuid}`, `death:{uuid}`, `marriage:{uuid}`) are structurally non-UUID and must be rejected by any write endpoint. See ADR-043.
**Notification** (`Notification`) — an in-app message delivered to an `AppUser`. No email or SMS delivery exists today. Delivered via Server-Sent Events (`SseEmitterRegistry`) and persisted in the `notifications` table.

View File

@@ -1,11 +1,12 @@
# ADR-041 — Adopt Spec-Driven Development (SDD)
# ADR-042 — Adopt Spec-Driven Development (SDD)
**Status:** Accepted
**Date:** 2026-06-13
**Issue:** SDD integration (docs/sdd-integration branch)
> This is the "ADR-000" the SDD scaffold refers to, numbered 041 to fit the existing archive
> sequence rather than starting a parallel one. See [`.specify/adrs/README.md`](../../.specify/adrs/README.md).
> This is the "ADR-000" the SDD scaffold refers to, numbered 042 to fit the existing archive
> sequence (041 was taken by the Renovate runner-setup ADR merged in parallel). See
> [`.specify/adrs/README.md`](../../.specify/adrs/README.md).
## Context

View File

@@ -0,0 +1,110 @@
# ADR-043 — Derived person life-events: on-read assembly strategy
**Status:** Proposed
**Date:** 2026-06-13
**Issue:** #776 — Timeline: derive person life-events (Geburt/Tod/Heirat)
---
## Context
The Zeitstrahl (family timeline) must surface births, deaths, and marriages alongside
manually curated `TimelineEvent` rows. This data already exists in the `Person` entity
(`birthDate`, `deathDate`, `birthDatePrecision`, `deathDatePrecision`) and in
`PersonRelationship` rows with `relationType = SPOUSE_OF`.
Three architectural decisions needed before implementation could start:
1. **Computation strategy:** should derived events be materialised to the `timeline_events`
table, or assembled on every read from the source tables?
2. **Id format:** how do we give derived events stable, unambiguous ids that cannot collide
with real `TimelineEvent` UUIDs and signal read-only semantics to consumers?
3. **Service contract:** where does the assembly method live, and what is its public API?
---
## Decision 1 — On-read assembly, never persisted
Derived events are computed on every call to `assembleDerivedEvents()` and are never written
to any table.
**Alternatives rejected:**
| Alternative | Reason rejected |
|-------------|-----------------|
| Materialise to `timeline_events` | Requires a synchronisation job or domain-event wiring every time a `Person` or `PersonRelationship` is mutated. Adds complexity, drift risk, and a write path for data that is fundamentally derived. |
| Separate `derived_events` table | Same sync problem; adds schema migration for data that is a pure projection. |
| Cache in-process | Adds invalidation complexity for MVP scale (tens to low hundreds of persons). Can be added later if `findAllFamilyMembers()` exceeds ~500 rows. |
**Consequences:**
- No schema changes. No Flyway migration.
- The method must be `@Transactional(readOnly = true)` to keep the Hibernate session open
across the lazy-association reads that `buildMarriageEvents()` performs via JOIN FETCH.
- Every caller of `assembleDerivedEvents()` triggers two DB queries: one for family-member
persons, one for spouse edges with JOIN FETCH. Acceptable at MVP scale.
---
## Decision 2 — Synthetic prefixed String ids
Derived events receive ids of the form `birth:{personId}`, `death:{personId}`,
`marriage:{relationshipId}`, where the suffix is the UUID of the source entity.
**Format rules:**
- `id` field on `TimelineEntryDTO` is typed `String`, NOT `UUID`.
- `UUID.fromString(derivedEvent.id())` always throws `IllegalArgumentException` — id is
structurally non-UUID by construction.
- The `unique_spouse_pair` DB index (V55) is the authoritative dedup guard for marriages;
the in-memory `Set<UUID>` used during assembly is a defensive assertion, not primary
enforcement.
**Alternatives rejected:**
| Alternative | Reason rejected |
|-------------|-----------------|
| Random UUID for each call | Not stable across calls — consumers (frontend, #5 sort/bucket) could not use ids as stable keys. |
| UUID typed field with a sentinel namespace (RFC 4122 v5) | Requires hashing; still looks like a UUID and could be confused with real event ids by write endpoints. |
| Numeric sequence | No natural source sequence; would require a counter, adding state. |
**Consequences:**
- `TimelineEntryDTO.id` must be `String`. The existing `TimelineEventView.id` is `UUID` and
serves a different purpose (CRUD admin view); it is not changed.
- Any write endpoint that accepts a timeline event id (`PUT`, `DELETE`) must reject ids that
do not parse as `UUID` — enforced and tested in issue #5, not here.
- Ids are deterministic and stable for the lifetime of the source entity, enabling client-side
caching and deduplication.
---
## Decision 3 — `assembleDerivedEvents()` as the public cross-issue contract
The assembly method lives on `TimelineService` as a `public` method. Issue #5 (the
`GET /api/timeline` endpoint) calls it directly on the injected `TimelineService` bean.
**Domain boundary rules enforced by this decision:**
- `TimelineService` reaches `Person` and `PersonRelationship` data **only through
`PersonService.findAllFamilyMembers()` and `RelationshipService.findAllSpouseEdges()`**.
It never injects `PersonRepository` or `PersonRelationshipRepository`.
- The three private builder methods (`buildBirthEvents`, `buildDeathEvents`,
`buildMarriageEvents`) are implementation details; only `assembleDerivedEvents()` is public.
- **Authorization:** `assembleDerivedEvents()` performs no authorization check. The calling
endpoint in #5 must enforce `READ_ALL` before invoking this method. Any future caller
outside #5 must do the same — this obligation is documented in the Javadoc of the method.
**Alternatives rejected:**
| Alternative | Reason rejected |
|-------------|-----------------|
| Separate `DerivedEventService` | Adds a class for a cohesive set of methods that belong to the timeline domain. Timeline owns the DTO shape; splitting it out is premature. |
| Expose via `PersonService` | Person domain should not know about `TimelineEntryDTO`. Cross-cutting concern belongs in timeline. |
---
## Related decisions
- ADR-039 — Person life-dates stored as `LocalDate` + `DatePrecision` (the source data this
issue reads)
- ADR-040 — Timeline domain data model (establishes the `timeline/` package and
`TimelineEvent` entity this issue extends)
- ADR-036 — Responses as views, never raw entities (why `assembleDerivedEvents()` returns
`List<TimelineEntryDTO>`, not raw `Person` or `PersonRelationship` entities)

View File

@@ -6,19 +6,28 @@ title Component Diagram: API Backend — Timeline (Zeitstrahl)
ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
System_Boundary(backend, "API Backend (Spring Boot)") {
Component(timelineRepo, "TimelineEventRepository", "Spring Data JPA", "Reads and writes TimelineEvent rows and their persons/documents join tables (timeline_event_persons, timeline_event_documents). Issue #774 ships the repository empty; the per-person filter query lands in #777.")
Component(timelineRepo, "TimelineEventRepository", "Spring Data JPA", "Reads and writes TimelineEvent rows and their persons/documents join tables (timeline_event_persons, timeline_event_documents).")
Component(timelineSvc, "TimelineEventService", "Spring Service (planned, #775)", "Will own curated-event CRUD: assemble TimelineEventView/Summary inside the transaction (lazy ManyToMany + open-in-view=false, per ADR-036/ADR-040), populate createdBy/updatedBy from the session principal, and translate optimistic-lock conflicts to DomainException.conflict.")
Component(timelineCtrl, "TimelineEventController", "Spring MVC (planned, #775)", "Will expose /api/timeline reads (READ_ALL) and writes (WRITE_ALL). createdBy/updatedBy are never bound from request bodies (CWE-639).")
Component(timelineSvc, "TimelineEventService", "Spring Service", "Owns curated-event CRUD: assembles TimelineEventView inside the transaction (lazy ManyToMany + open-in-view=false, ADR-036/ADR-040), populates createdBy/updatedBy from the session principal, and translates optimistic-lock conflicts to DomainException.conflict. Also exposes assembleDerivedEvents(): computes Geburt/Tod/Heirat TimelineEntryDTOs on read from Person/PersonRelationship data — never persisted (ADR-043).")
Component(timelineCtrl, "TimelineEventController", "Spring MVC", "Exposes /api/timeline/events reads (READ_ALL) and writes (WRITE_ALL). createdBy/updatedBy are never bound from request bodies (CWE-639).")
Component(timelineAssemblySvc, "TimelineService", "Spring Service", "Assembles GET /api/timeline response: merges curated TimelineEvent rows, derived life-events (via TimelineEventService), and archive letters (via DocumentService) into a year-bucketed TimelineDTO. Applies personId, generation, type, fromYear/toYear filters. WITHIN_BAND_ORDER: precision rank desc → date asc → title alpha → id tiebreak.")
Component(timelineAssemblyCtrl, "TimelineController", "Spring MVC", "Exposes GET /api/timeline (READ_ALL). Five optional query params: personId, generation (@Min(0)), type (EventType enum), fromYear, toYear. @Validated on class for constraint enforcement.")
}
System_Ext(documentDomain, "Document domain", "Provides DatePrecision (shared value type) and Document references for linked letters")
System_Ext(personDomain, "Person domain", "Provides Person references for who an event involves")
System_Ext(documentDomain, "Document domain", "Provides DatePrecision (shared value type), Document references for linked letters, and getAllForTimeline() bulk fetch")
System_Ext(personDomain, "Person domain", "Provides Person references (PersonService.findAllFamilyMembers, getPersonsByGeneration, getById) and SPOUSE_OF edges (RelationshipService.findAllSpouseEdges) for derived-event assembly and generation filtering")
Rel(timelineRepo, db, "SQL queries", "JDBC")
Rel(timelineSvc, timelineRepo, "Reads / writes events (planned)")
Rel(timelineCtrl, timelineSvc, "Delegates to (planned)")
Rel(timelineSvc, timelineRepo, "Reads / writes events")
Rel(timelineCtrl, timelineSvc, "Delegates to")
Rel(timelineRepo, personDomain, "References persons via join table")
Rel(timelineRepo, documentDomain, "References documents via join table")
Rel(timelineSvc, personDomain, "findAllFamilyMembers() + findAllSpouseEdges() for derived-event assembly")
Rel(timelineAssemblyCtrl, timelineAssemblySvc, "Delegates to")
Rel(timelineAssemblySvc, timelineRepo, "findAll() for curated events")
Rel(timelineAssemblySvc, timelineSvc, "assembleDerivedEvents() for derived life-events")
Rel(timelineAssemblySvc, personDomain, "getPersonsByGeneration(), getById() for generation/personId filters")
Rel(timelineAssemblySvc, documentDomain, "getAllForTimeline(), getDocumentsBySender(), getDocumentsByReceiver() for letter layer")
@enduml

View File

@@ -14,6 +14,8 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story/Journey list and detail pages. List: GeschichteListRow with REISE badge for JOURNEY type. Detail: dispatches to StoryReader (rich text + persons) or JourneyReader (intro + ordered JourneyItemCard/JourneyInterlude items + empty state) based on GeschichteType. BLOG_WRITE users see edit/delete actions. Loader: GET /api/geschichten, GET /api/geschichten/{id}.")
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}.")
@@ -27,6 +29,9 @@ Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications"
Rel(geschichten, backend, "GET /api/geschichten, GET /api/geschichten/{id}, DELETE /api/geschichten/{id}", "HTTP / JSON")
Rel(geschichtenEdit, backend, "GET /api/persons/{id} (pre-populate), POST /api/geschichten, PUT /api/geschichten/{id}, POST/DELETE /api/geschichten/{id}/items, PUT /api/geschichten/{id}/items/reorder, PATCH /api/geschichten/{id}/items/{itemId}", "HTTP / JSON")
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,6 +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; events/new + events/[id]/edit curator editor (WRITE_ALL-gated)
│ ├── enrich/ # Enrichment workflow ([id], done)
│ ├── hilfe/transkription/ # Transcription help page
│ ├── profile/ # User profile settings
@@ -49,6 +50,7 @@ src/
│ │ ├── relationship/ # Relationship form + chip components
│ │ └── genealogy/ # Stammbaum (family tree) components
│ ├── tag/ # Tag domain: TagInput, TagChipList, TagParentPicker
│ ├── timeline/ # Timeline (Zeitstrahl) domain: TimelineView, YearBand, EventPill, WorldBand, LetterCard, YearLetterStrip, GapSpan; dateLabel + timelineDensity + eventCardConfig (imports $lib/shared only, never document/)
│ ├── geschichte/ # Geschichte (story) domain: editor + card
│ ├── notification/ # Notification bell + dropdown + store
│ ├── activity/ # Activity feed (Chronik) components
@@ -59,8 +61,8 @@ src/
│ │ ├── hooks/ # Reusable Svelte state hooks (useTypeahead, etc.)
│ │ ├── server/ # Server-only utilities (locale, session)
│ │ ├── services/ # Client-side service helpers
│ │ ├── utils/ # Pure utility functions (date, search, etc.)
│ │ ├── primitives/ # Generic UI primitives (BackButton, ProgressRing, etc.)
│ │ ├── utils/ # Pure utility functions (date, search, monthBuckets — month-bucket math shared by document chart + timeline strip)
│ │ ├── primitives/ # Generic UI primitives (BackButton, ProgressRing, Sparkline, etc.)
│ │ ├── dashboard/ # Dashboard stat components
│ │ ├── discussion/ # CommentThread + shared discussion UI
│ │ ├── help/ # Help/FAQ page components

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

@@ -0,0 +1,84 @@
import { test, expect, type APIRequestContext } from '@playwright/test';
/**
* Global /zeitstrahl timeline (#779). Runs against the real stack with the
* seeded admin session (auth.setup). Covers the primary journey (nav → page,
* timeline inside <main>) and the 320px no-overflow guarantee on a populated
* timeline seeded with 25+char correspondent names (REQ-005).
*/
const stamp = () => new Date().toISOString().replace(/[^0-9]/g, '');
async function createPerson(request: APIRequestContext, firstName: string, lastName: string) {
const res = await request.post('/api/persons', {
data: { personType: 'PERSON', firstName, lastName }
});
if (!res.ok()) throw new Error(`create person failed: ${res.status()}`);
return (await res.json()).id as string;
}
/** Seeds one dated letter with long sender/receiver names so it lands on the timeline. */
async function seedDatedLetter(request: APIRequestContext) {
const senderId = await createPerson(
request,
'Friedrich-Wilhelm',
`Maximilian von Habsburg ${stamp()}`
);
const receiverId = await createPerson(
request,
'Maria-Magdalena',
`Hohenzollern-Sigmaringen ${stamp()}`
);
const createRes = await request.post('/api/documents', {
multipart: { title: `E2E Zeitstrahl Brief ${stamp()}` }
});
if (!createRes.ok()) throw new Error(`create document failed: ${createRes.status()}`);
const docId = (await createRes.json()).id as string;
const put = await request.put(`/api/documents/${docId}`, {
multipart: {
title: `E2E Zeitstrahl Brief ${stamp()}`,
documentDate: '1915-06-15',
metaDatePrecision: 'DAY',
senderId,
receiverIds: receiverId
}
});
if (!put.ok()) throw new Error(`update document failed: ${put.status()}`);
}
test.describe('Zeitstrahl — global timeline (#779)', () => {
test('nav link opens /zeitstrahl and the timeline lives in <main>', async ({ page }) => {
await page.goto('/');
await page.getByRole('navigation').getByRole('link', { name: 'Zeitstrahl' }).first().click();
await expect(page).toHaveURL(/\/zeitstrahl$/);
await expect(page.getByRole('heading', { level: 1, name: 'Zeitstrahl' })).toBeVisible();
// The main landmark contains either the populated <ol> or the empty state.
const main = page.getByRole('main');
const ol = main.locator('ol');
const empty = main.getByText('Noch keine Ereignisse.');
await expect(async () => {
const populated = (await ol.count()) > 0;
const isEmpty = await empty.isVisible().catch(() => false);
expect(populated || isEmpty).toBe(true);
}).toPass();
});
test('no horizontal overflow at 320px with long correspondent names (REQ-005)', async ({
page,
request
}) => {
await seedDatedLetter(request);
await page.setViewportSize({ width: 320, height: 900 });
await page.goto('/zeitstrahl');
// Populated: the seeded letter puts the timeline <ol> in the DOM.
await expect(page.getByRole('main').locator('ol')).toHaveCount(1);
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' },
@@ -215,6 +220,7 @@ export default defineConfig(
'ocr',
'activity',
'conversation',
'timeline',
'shared'
]
}

View File

@@ -1032,6 +1032,55 @@
"bulk_edit_count_pill": "{count} werden bearbeitet",
"nav_stammbaum": "Stammbaum",
"nav_geschichten": "Geschichten",
"nav_zeitstrahl": "Zeitstrahl",
"timeline_heading": "Zeitstrahl",
"timeline_empty_state": "Noch keine Ereignisse.",
"timeline_undated_section": "Ohne Datum",
"timeline_unknown_person": "Unbekannt",
"timeline_gap_empty": "keine Einträge",
"timeline_letters_count": "{count} Briefe",
"timeline_strip_expand": "Briefe anzeigen",
"timeline_range_aria": "Zeitraum: {from} bis {to}",
"timeline_layer_world": "Weltgeschehen",
"timeline_layer_family": "Familie",
"timeline_derived_birth": "Geburt",
"timeline_derived_death": "Tod",
"timeline_derived_marriage": "Heirat",
"timeline_grouping_date": "Gruppierung: Datum",
"timeline_provenance_derived": "abgeleitet",
"timeline_provenance_curated": "kuratiert",
"timeline_letter_glyph_label": "Brief",
"timeline_layer_historical_suffix": "historisch",
"timeline_strip_density_caption": "Monats-Dichte",
"timeline_events_count": "{count} Ereignisse",
"timeline_letters_count_singular": "1 Brief",
"timeline_events_count_singular": "1 Ereignis",
"event_editor_new_title": "Neues Ereignis",
"event_editor_edit_title": "Ereignis bearbeiten",
"event_editor_section_when": "Wann",
"event_editor_section_persons": "Beteiligte Personen",
"event_editor_section_documents": "Verknüpfte Briefe",
"event_editor_section_description": "Beschreibung",
"event_editor_title_label": "Titel",
"event_editor_title_placeholder": "Titel des Ereignisses",
"event_editor_title_required": "Bitte einen Titel eingeben.",
"event_editor_date_required": "Bitte ein Datum eingeben.",
"event_editor_end_date_required": "Bitte ein Enddatum eingeben.",
"event_editor_type_label": "Typ",
"event_editor_persons_label": "Personen",
"event_editor_documents_label": "Briefe",
"event_editor_description_label": "Beschreibung",
"event_editor_description_placeholder": "Optionale Beschreibung",
"event_editor_persons_empty": "Noch keine Person verknüpft",
"event_editor_documents_empty": "Noch kein Dokument verknüpft",
"event_type_PERSONAL": "Persönlich",
"event_type_HISTORICAL": "Historisch",
"event_editor_save": "Speichern",
"event_editor_save_hint": "Ereignisse erscheinen im Zeitstrahl.",
"event_editor_delete": "Löschen",
"event_editor_delete_confirm_title": "Ereignis löschen?",
"event_editor_delete_confirm_body": "Dieses Ereignis wird dauerhaft entfernt.",
"event_editor_unsaved_changes": "Du hast ungespeicherte Änderungen — wirklich verlassen?",
"error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.",
"error_journey_item_not_found": "Der Reise-Eintrag wurde nicht gefunden.",
"error_journey_item_position_conflict": "Die Reihenfolge wurde gerade von jemand anderem geändert bitte laden Sie die Seite neu.",

View File

@@ -1032,6 +1032,55 @@
"bulk_edit_count_pill": "{count} will be edited",
"nav_stammbaum": "Family tree",
"nav_geschichten": "Stories",
"nav_zeitstrahl": "Timeline",
"timeline_heading": "Timeline",
"timeline_empty_state": "No events yet.",
"timeline_undated_section": "Without Date",
"timeline_unknown_person": "Unknown",
"timeline_gap_empty": "no entries",
"timeline_letters_count": "{count} letters",
"timeline_strip_expand": "Show letters",
"timeline_range_aria": "Period: {from} to {to}",
"timeline_layer_world": "World events",
"timeline_layer_family": "Family",
"timeline_derived_birth": "Birth",
"timeline_derived_death": "Death",
"timeline_derived_marriage": "Marriage",
"timeline_grouping_date": "Grouping: Date",
"timeline_provenance_derived": "derived",
"timeline_provenance_curated": "curated",
"timeline_letter_glyph_label": "Letter",
"timeline_layer_historical_suffix": "historical",
"timeline_strip_density_caption": "Monthly density",
"timeline_events_count": "{count} events",
"timeline_letters_count_singular": "1 letter",
"timeline_events_count_singular": "1 event",
"event_editor_new_title": "New event",
"event_editor_edit_title": "Edit event",
"event_editor_section_when": "When",
"event_editor_section_persons": "People involved",
"event_editor_section_documents": "Linked letters",
"event_editor_section_description": "Description",
"event_editor_title_label": "Title",
"event_editor_title_placeholder": "Event title",
"event_editor_title_required": "Please enter a title.",
"event_editor_date_required": "Please enter a date.",
"event_editor_end_date_required": "Please enter an end date.",
"event_editor_type_label": "Type",
"event_editor_persons_label": "People",
"event_editor_documents_label": "Letters",
"event_editor_description_label": "Description",
"event_editor_description_placeholder": "Optional description",
"event_editor_persons_empty": "No person linked yet",
"event_editor_documents_empty": "No document linked yet",
"event_type_PERSONAL": "Personal",
"event_type_HISTORICAL": "Historical",
"event_editor_save": "Save",
"event_editor_save_hint": "Events appear on the timeline.",
"event_editor_delete": "Delete",
"event_editor_delete_confirm_title": "Delete event?",
"event_editor_delete_confirm_body": "This event will be permanently removed.",
"event_editor_unsaved_changes": "You have unsaved changes — really leave?",
"error_geschichte_not_found": "The story was not found.",
"error_journey_item_not_found": "The journey item was not found.",
"error_journey_item_position_conflict": "The order was just changed by someone else — please reload the page.",

View File

@@ -1032,6 +1032,55 @@
"bulk_edit_count_pill": "Se editarán {count}",
"nav_stammbaum": "Árbol genealógico",
"nav_geschichten": "Historias",
"nav_zeitstrahl": "Línea de tiempo",
"timeline_heading": "Línea de tiempo",
"timeline_empty_state": "Aún no hay eventos.",
"timeline_undated_section": "Sin Fecha",
"timeline_unknown_person": "Desconocido",
"timeline_gap_empty": "sin entradas",
"timeline_letters_count": "{count} cartas",
"timeline_strip_expand": "Mostrar cartas",
"timeline_range_aria": "Período: {from} a {to}",
"timeline_layer_world": "Acontecimientos mundiales",
"timeline_layer_family": "Familia",
"timeline_derived_birth": "Nacimiento",
"timeline_derived_death": "Fallecimiento",
"timeline_derived_marriage": "Matrimonio",
"timeline_grouping_date": "Agrupación: Fecha",
"timeline_provenance_derived": "derivado",
"timeline_provenance_curated": "curado",
"timeline_letter_glyph_label": "Carta",
"timeline_layer_historical_suffix": "histórico",
"timeline_strip_density_caption": "Densidad mensual",
"timeline_events_count": "{count} eventos",
"timeline_letters_count_singular": "1 carta",
"timeline_events_count_singular": "1 evento",
"event_editor_new_title": "Nuevo evento",
"event_editor_edit_title": "Editar evento",
"event_editor_section_when": "Cuándo",
"event_editor_section_persons": "Personas involucradas",
"event_editor_section_documents": "Cartas vinculadas",
"event_editor_section_description": "Descripción",
"event_editor_title_label": "Título",
"event_editor_title_placeholder": "Título del evento",
"event_editor_title_required": "Por favor, introduzca un título.",
"event_editor_date_required": "Por favor, introduzca una fecha.",
"event_editor_end_date_required": "Por favor, introduzca una fecha de fin.",
"event_editor_type_label": "Tipo",
"event_editor_persons_label": "Personas",
"event_editor_documents_label": "Cartas",
"event_editor_description_label": "Descripción",
"event_editor_description_placeholder": "Descripción opcional",
"event_editor_persons_empty": "Aún no hay ninguna persona vinculada",
"event_editor_documents_empty": "Aún no hay ningún documento vinculado",
"event_type_PERSONAL": "Personal",
"event_type_HISTORICAL": "Histórico",
"event_editor_save": "Guardar",
"event_editor_save_hint": "Los eventos aparecen en la cronología.",
"event_editor_delete": "Eliminar",
"event_editor_delete_confirm_title": "¿Eliminar evento?",
"event_editor_delete_confirm_body": "Este evento se eliminará de forma permanente.",
"event_editor_unsaved_changes": "Tienes cambios sin guardar — ¿salir de todos modos?",
"error_geschichte_not_found": "No se encontró la historia.",
"error_journey_item_not_found": "No se encontró el elemento del viaje.",
"error_journey_item_position_conflict": "El orden fue cambiado por otra persona — por favor recargue la página.",

View File

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

View File

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

View File

@@ -30,6 +30,7 @@ Sub-folders: `annotation/`, `transcription/`, `viewer/`.
- `tag/TagInput.svelte` — tag chip input
- `ocr/OcrProgress.svelte` — job status indicator in the document header
- `shared/primitives/BackButton.svelte`, `shared/discussion/` — shared UI
- `shared/utils/monthBuckets.ts` — the density chart's pure month-bucket math (boundaries, gap-fill, year aggregation, axis ticks) now lives in `shared/` so the `timeline/` domain can reuse it; `document/timeline.ts` keeps only the `/api/documents/density` glue (`fetchDensity`, `buildDensityUrl`)
## Backend counterpart

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { formatTickLabel } from '$lib/document/timeline';
import { formatTickLabel } from '$lib/shared/utils/monthBuckets';
import { getLocale } from '$lib/paraglide/runtime';
import type { components } from '$lib/generated/api';

View File

@@ -7,7 +7,7 @@ import {
selectionBoundaryFrom,
selectionBoundaryTo,
formatTickLabel
} from '$lib/document/timeline';
} from '$lib/shared/utils/monthBuckets';
import { createTimelineDrag } from '$lib/document/useTimelineDrag.svelte';
import { getLocale } from '$lib/paraglide/runtime';
import TimelineBars from '$lib/document/TimelineBars.svelte';

View File

@@ -3,7 +3,7 @@ import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import { tick } from 'svelte';
import TimelineDensityFilter from './TimelineDensityFilter.svelte';
import { formatTickLabel } from './timeline';
import { formatTickLabel } from '$lib/shared/utils/monthBuckets';
import { getLocale } from '$lib/paraglide/runtime';
import type { components } from '$lib/generated/api';

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { formatTickLabel, tickIndicesFor } from '$lib/document/timeline';
import { formatTickLabel, tickIndicesFor } from '$lib/shared/utils/monthBuckets';
import { getLocale } from '$lib/paraglide/runtime';
import type { components } from '$lib/generated/api';

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}
<!-- 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"
/>
<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 : ''} />
{/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

@@ -1,191 +1,5 @@
import { describe, it, expect, vi } from 'vitest';
import {
monthBoundaryFrom,
monthBoundaryTo,
buildMonthSequence,
fillDensityGaps,
fetchDensity,
buildDensityUrl,
aggregateToYears,
selectionBoundaryFrom,
selectionBoundaryTo,
clipBucketsToRange,
tickIndicesFor,
formatTickLabel
} from './timeline';
describe('monthBoundaryFrom', () => {
it('returns the first day of the given month', () => {
expect(monthBoundaryFrom('1915-08')).toBe('1915-08-01');
});
it('handles January', () => {
expect(monthBoundaryFrom('1920-01')).toBe('1920-01-01');
});
});
describe('monthBoundaryTo', () => {
it('returns the last day of a 31-day month', () => {
expect(monthBoundaryTo('1915-08')).toBe('1915-08-31');
});
it('returns the last day of a 30-day month', () => {
expect(monthBoundaryTo('1915-04')).toBe('1915-04-30');
});
it('returns 28 for February in a non-leap year', () => {
expect(monthBoundaryTo('1915-02')).toBe('1915-02-28');
});
it('returns 29 for February in a leap year', () => {
expect(monthBoundaryTo('1916-02')).toBe('1916-02-29');
});
});
describe('buildMonthSequence', () => {
it('returns a single month when min and max are in the same month', () => {
expect(buildMonthSequence('1915-08-03', '1915-08-22')).toEqual(['1915-08']);
});
it('returns months from minDate through maxDate inclusive', () => {
expect(buildMonthSequence('1915-08-03', '1915-11-15')).toEqual([
'1915-08',
'1915-09',
'1915-10',
'1915-11'
]);
});
it('crosses year boundaries correctly', () => {
expect(buildMonthSequence('1915-11-30', '1916-02-01')).toEqual([
'1915-11',
'1915-12',
'1916-01',
'1916-02'
]);
});
it('returns empty array when minDate or maxDate is null', () => {
expect(buildMonthSequence(null, '1915-08-01')).toEqual([]);
expect(buildMonthSequence('1915-08-01', null)).toEqual([]);
expect(buildMonthSequence(null, null)).toEqual([]);
});
});
describe('fillDensityGaps', () => {
it('returns empty array when minDate or maxDate is null', () => {
expect(fillDensityGaps([], null, null)).toEqual([]);
});
it('preserves existing buckets and adds zero-count buckets for missing months', () => {
const buckets = [
{ month: '1915-08', count: 5 },
{ month: '1915-11', count: 2 }
];
const result = fillDensityGaps(buckets, '1915-08-03', '1915-11-30');
expect(result).toEqual([
{ month: '1915-08', count: 5 },
{ month: '1915-09', count: 0 },
{ month: '1915-10', count: 0 },
{ month: '1915-11', count: 2 }
]);
});
it('returns all-zero sequence when buckets array is empty', () => {
const result = fillDensityGaps([], '1915-08-03', '1915-10-15');
expect(result).toEqual([
{ month: '1915-08', count: 0 },
{ month: '1915-09', count: 0 },
{ month: '1915-10', count: 0 }
]);
});
it('keeps results sorted chronologically even when buckets arrive out of order', () => {
const buckets = [
{ month: '1915-10', count: 3 },
{ month: '1915-08', count: 1 }
];
const result = fillDensityGaps(buckets, '1915-08-01', '1915-10-31');
expect(result.map((b) => b.month)).toEqual(['1915-08', '1915-09', '1915-10']);
});
});
describe('aggregateToYears', () => {
it('returns empty array for empty input', () => {
expect(aggregateToYears([])).toEqual([]);
});
it('sums counts within the same year', () => {
const result = aggregateToYears([
{ month: '1915-08', count: 5 },
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 8 }
]);
expect(result).toEqual([{ month: '1915', count: 15 }]);
});
it('produces one bucket per distinct year, sorted chronologically', () => {
const result = aggregateToYears([
{ month: '1916-01', count: 3 },
{ month: '1915-08', count: 5 },
{ month: '1916-04', count: 7 },
{ month: '1914-12', count: 1 }
]);
expect(result).toEqual([
{ month: '1914', count: 1 },
{ month: '1915', count: 5 },
{ month: '1916', count: 10 }
]);
});
});
describe('clipBucketsToRange', () => {
const buckets = [
{ month: '1915-08', count: 5 },
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 8 },
{ month: '1915-11', count: 3 }
];
it('returns the original buckets when range bounds are null', () => {
expect(clipBucketsToRange(buckets, null, null)).toBe(buckets);
});
it('keeps only buckets whose month falls within the range', () => {
expect(clipBucketsToRange(buckets, '1915-09-01', '1915-10-31')).toEqual([
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 8 }
]);
});
it('returns an empty array when the range excludes everything', () => {
expect(clipBucketsToRange(buckets, '1916-01-01', '1916-12-31')).toEqual([]);
});
it('treats partial dates correctly when bounds cross month boundaries', () => {
expect(clipBucketsToRange(buckets, '1915-09-15', '1915-10-15')).toEqual([
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 8 }
]);
});
});
describe('selectionBoundaryFrom / To', () => {
it('handles month labels (YYYY-MM)', () => {
expect(selectionBoundaryFrom('1915-08')).toBe('1915-08-01');
expect(selectionBoundaryTo('1915-08')).toBe('1915-08-31');
});
it('handles year labels (YYYY)', () => {
expect(selectionBoundaryFrom('1915')).toBe('1915-01-01');
expect(selectionBoundaryTo('1915')).toBe('1915-12-31');
});
});
import { fetchDensity, buildDensityUrl } from './timeline';
describe('buildDensityUrl', () => {
it('returns the bare endpoint when no filters provided', () => {
@@ -309,84 +123,3 @@ describe('fetchDensity', () => {
warn.mockRestore();
});
});
describe('tickIndicesFor', () => {
it('returns no indices for an empty bucket list', () => {
expect(tickIndicesFor([])).toEqual([]);
});
it('picks years divisible by 25 when the year span exceeds 120', () => {
const buckets = Array.from({ length: 150 }, (_, i) => ({
month: String(1875 + i),
count: 1
}));
const ticks = tickIndicesFor(buckets);
const labels = ticks.map((i) => buckets[i].month);
expect(labels).toEqual(['1875', '1900', '1925', '1950', '1975', '2000']);
});
it('picks years divisible by 10 for medium ranges (~50 years)', () => {
const buckets = Array.from({ length: 50 }, (_, i) => ({
month: String(1900 + i),
count: 1
}));
const ticks = tickIndicesFor(buckets);
const labels = ticks.map((i) => buckets[i].month);
expect(labels).toEqual(['1900', '1910', '1920', '1930', '1940']);
});
it('picks January boundaries for long month ranges', () => {
const buckets = [
{ month: '1914-08', count: 1 },
{ month: '1914-09', count: 1 },
{ month: '1914-10', count: 1 },
{ month: '1914-11', count: 1 },
{ month: '1914-12', count: 1 },
{ month: '1915-01', count: 1 },
{ month: '1915-02', count: 1 },
{ month: '1915-03', count: 1 },
{ month: '1915-04', count: 1 },
{ month: '1915-05', count: 1 },
{ month: '1915-06', count: 1 },
{ month: '1915-07', count: 1 },
{ month: '1915-08', count: 1 },
{ month: '1915-09', count: 1 },
{ month: '1915-10', count: 1 },
{ month: '1915-11', count: 1 },
{ month: '1915-12', count: 1 },
{ month: '1916-01', count: 1 },
{ month: '1916-02', count: 1 }
];
const ticks = tickIndicesFor(buckets);
expect(ticks.map((i) => buckets[i].month)).toEqual(['1915-01', '1916-01']);
});
it('falls back to evenly spaced ticks for short month ranges (12 months)', () => {
const buckets = Array.from({ length: 12 }, (_, i) => ({
month: `1905-${String(i + 1).padStart(2, '0')}`,
count: 1
}));
const ticks = tickIndicesFor(buckets);
expect(ticks.length).toBeGreaterThanOrEqual(5);
expect(ticks.length).toBeLessThanOrEqual(7);
expect(ticks[0]).toBe(0);
});
});
describe('formatTickLabel', () => {
it('returns the year string unchanged for year labels', () => {
expect(formatTickLabel('1905', 'en-US')).toBe('1905');
});
it('formats month labels with the year by default', () => {
const result = formatTickLabel('1905-06', 'en-US');
expect(result).toMatch(/Jun/);
expect(result).toMatch(/1905/);
});
it('omits the year when omitYear is true', () => {
const result = formatTickLabel('1905-06', 'en-US', true);
expect(result).toMatch(/Jun/);
expect(result).not.toMatch(/1905/);
});
});

View File

@@ -12,160 +12,6 @@ export type DensityState = {
const SKIP: DensityState = { density: null, minDate: null, maxDate: null };
const EMPTY: DensityState = { density: [], minDate: null, maxDate: null };
export function monthBoundaryFrom(yearMonth: string): string {
return `${yearMonth}-01`;
}
export function monthBoundaryTo(yearMonth: string): string {
const [year, month] = yearMonth.split('-').map(Number);
// Day 0 of `month + 1` rolls back to the last day of `month` — so passing
// `month` (1-indexed) into `Date.UTC(year, month, 0)` lands on the last day
// of that month. Handles 28/29/30/31 and leap years without a lookup table.
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
return `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
}
export function buildMonthSequence(minDate: string | null, maxDate: string | null): string[] {
if (!minDate || !maxDate) return [];
const [minY, minM] = minDate.split('-').map(Number);
const [maxY, maxM] = maxDate.split('-').map(Number);
const sequence: string[] = [];
let year = minY;
let month = minM;
while (year < maxY || (year === maxY && month <= maxM)) {
sequence.push(`${year}-${String(month).padStart(2, '0')}`);
month += 1;
if (month > 12) {
month = 1;
year += 1;
}
}
return sequence;
}
export function fillDensityGaps(
buckets: MonthBucket[],
minDate: string | null,
maxDate: string | null
): MonthBucket[] {
const sequence = buildMonthSequence(minDate, maxDate);
if (sequence.length === 0) return [];
const counts = new Map(buckets.map((b) => [b.month, b.count]));
return sequence.map((month) => ({ month, count: counts.get(month) ?? 0 }));
}
/**
* Returns only the month buckets whose YYYY-MM falls inside the provided
* `[fromInclusive, toInclusive]` ISO date range. When either bound is null the
* input array is returned unchanged. Used by the timeline's zoom-in tool to
* narrow the visible bars without refetching data.
*
* @internal Sole call site is `TimelineDensityFilter.svelte`. Exported so the
* unit suite (`timeline.spec.ts`) can pin the boundary semantics directly.
*/
export function clipBucketsToRange(
buckets: MonthBucket[],
fromInclusive: string | null,
toInclusive: string | null
): MonthBucket[] {
if (!fromInclusive || !toInclusive) return buckets;
const fromMonth = fromInclusive.slice(0, 7);
const toMonth = toInclusive.slice(0, 7);
return buckets.filter((b) => b.month >= fromMonth && b.month <= toMonth);
}
/**
* Aggregates month-granular buckets into one entry per year. Month strings are
* truncated to "YYYY" and counts are summed. Used when the date span is too
* long for month-granular bars to render at a clickable size.
*/
export function aggregateToYears(buckets: MonthBucket[]): MonthBucket[] {
const totals = new Map<string, number>();
for (const b of buckets) {
const year = b.month.slice(0, 4);
totals.set(year, (totals.get(year) ?? 0) + b.count);
}
return Array.from(totals.entries())
.map(([year, count]) => ({ month: year, count }))
.sort((a, b) => a.month.localeCompare(b.month));
}
/**
* Boundary helpers for selection. Accept either "YYYY-MM" (month) or "YYYY"
* (year) and return the matching LocalDate string.
*/
export function selectionBoundaryFrom(label: string): string {
return label.length === 4 ? `${label}-01-01` : `${label}-01`;
}
export function selectionBoundaryTo(label: string): string {
if (label.length === 4) return `${label}-12-31`;
return monthBoundaryTo(label);
}
/**
* Picks bucket indices that should get an X-axis tick label. The strategy adapts
* to whether bars are years or months and how many are visible:
* - Year bars: pick years divisible by a step that scales with range length
* (every 25 yrs for >120 bars, every 20 / 10 / 5 / 1 below).
* - Month bars: prefer January boundaries (year breaks). For ≤18 bars (e.g.
* one year zoomed in to months), fall back to evenly spaced ticks so we
* show ~6 labels even when no January boundary exists.
*/
export function tickIndicesFor(filled: MonthBucket[]): number[] {
if (filled.length === 0) return [];
const isYearMode = filled[0].month.length === 4;
const indices: number[] = [];
if (isYearMode) {
const years = filled.length;
const step =
years > 120 ? 25 : years > 60 ? 20 : years > 30 ? 10 : years > 12 ? 5 : years > 6 ? 2 : 1;
for (let i = 0; i < filled.length; i++) {
const year = parseInt(filled[i].month, 10);
if (year % step === 0) indices.push(i);
}
return indices;
}
if (filled.length <= 18) {
const step = Math.max(1, Math.round(filled.length / 6));
for (let i = 0; i < filled.length; i += step) indices.push(i);
return indices;
}
// Long month range — pick January boundaries (year breaks).
for (let i = 0; i < filled.length; i++) {
if (filled[i].month.endsWith('-01')) indices.push(i);
}
// Fallback if there's no January in the visible range (rare): even spacing.
if (indices.length === 0) {
const step = Math.max(1, Math.round(filled.length / 6));
for (let i = 0; i < filled.length; i += step) indices.push(i);
}
return indices;
}
/**
* Formats a bucket month label ("YYYY" or "YYYY-MM") for the X-axis. When
* `omitYear` is true the year is dropped so a 12-month zoomed view reads as
* "Jan", "Feb", … without repetition.
*/
export function formatTickLabel(label: string, locale?: string, omitYear = false): string {
if (label.length === 4) return label;
const [yearStr, monthStr] = label.split('-');
const date = new Date(parseInt(yearStr, 10), parseInt(monthStr, 10) - 1, 1);
const opts: Intl.DateTimeFormatOptions = omitYear
? { month: 'short' }
: { month: 'short', year: 'numeric' };
return new Intl.DateTimeFormat(locale, opts).format(date);
}
/**
* The subset of /documents URL params that should narrow the density chart.
* Date bounds (`from`/`to`) are intentionally excluded — see

View File

@@ -1032,6 +1032,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/timeline": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getTimeline"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/tags": {
parameters: {
query?: never;
@@ -2413,6 +2429,38 @@ export interface components {
contributors: components["schemas"]["ActivityActorDTO"][];
hasMoreContributors: boolean;
};
TimelineDTO: {
years: components["schemas"]["TimelineYearDTO"][];
undated: components["schemas"]["TimelineEntryDTO"][];
};
TimelineEntryDTO: {
/** @enum {string} */
kind: "EVENT" | "LETTER";
/** @enum {string} */
precision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
derived: boolean;
senderName: string;
receiverName: string;
/** Format: date */
eventDate?: string;
/** Format: date */
eventDateEnd?: string;
title?: string;
/** @enum {string} */
type?: "PERSONAL" | "HISTORICAL";
/** Format: uuid */
eventId?: string;
/** Format: uuid */
documentId?: string;
linkedPersonIds?: string[];
/** @enum {string} */
derivedType?: "BIRTH" | "DEATH" | "MARRIAGE";
};
TimelineYearDTO: {
/** Format: int32 */
year: number;
entries: components["schemas"]["TimelineEntryDTO"][];
};
TagTreeNodeDTO: {
/** Format: uuid */
id: string;
@@ -2468,10 +2516,10 @@ export interface components {
birthDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
/** Format: date */
deathDate?: string;
/** @enum {string} */
deathDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
personType?: string;
familyMember?: boolean;
/** @enum {string} */
deathDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
provisional?: boolean;
/** Format: int32 */
birthYear?: number;
@@ -4993,6 +5041,32 @@ export interface operations {
};
};
};
getTimeline: {
parameters: {
query?: {
personId?: string;
generation?: number;
type?: "PERSONAL" | "HISTORICAL";
fromYear?: number;
toYear?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["TimelineDTO"];
};
};
};
};
searchTags: {
parameters: {
query?: {

View File

@@ -40,4 +40,54 @@ describe('message key parity', () => {
expect(es).toHaveProperty('layout_menu_open');
expect(es).toHaveProperty('layout_menu_close');
});
// REQ-024: the timeline layer/life-event labels feed sr-only / aria text, so
// they are localized per locale (the original German-only MVP decision was
// reversed for accessibility). Pin the values so en/es can never silently
// drift back to the German source strings.
it('timeline layer/derived labels are localized per locale (REQ-024)', () => {
expect(de).toMatchObject({
timeline_layer_world: 'Weltgeschehen',
timeline_layer_family: 'Familie',
timeline_derived_birth: 'Geburt',
timeline_derived_death: 'Tod',
timeline_derived_marriage: 'Heirat'
});
expect(en).toMatchObject({
timeline_layer_world: 'World events',
timeline_layer_family: 'Family',
timeline_derived_birth: 'Birth',
timeline_derived_death: 'Death',
timeline_derived_marriage: 'Marriage'
});
expect(es).toMatchObject({
timeline_layer_world: 'Acontecimientos mundiales',
timeline_layer_family: 'Familia',
timeline_derived_birth: 'Nacimiento',
timeline_derived_death: 'Fallecimiento',
timeline_derived_marriage: 'Matrimonio'
});
});
// #833 REQ-015: the new visual-fidelity strings (meta line, provenance token,
// ✉ label, world-band suffix, density caption) are Paraglide keys present in
// every locale so no surface ever falls back to a missing translation.
it('zeitstrahl visual-fidelity keys are present in all locales (#833 REQ-015)', () => {
const requiredKeys = [
'timeline_grouping_date',
'timeline_provenance_derived',
'timeline_provenance_curated',
'timeline_letter_glyph_label',
'timeline_layer_historical_suffix',
'timeline_strip_density_caption',
'timeline_events_count',
'timeline_letters_count_singular',
'timeline_events_count_singular'
];
for (const key of requiredKeys) {
expect(de, `missing key in de: ${key}`).toHaveProperty(key);
expect(en, `missing key in en: ${key}`).toHaveProperty(key);
expect(es, `missing key in es: ${key}`).toHaveProperty(key);
}
});
});

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

@@ -15,11 +15,13 @@ If any condition fails, the file belongs in the domain folder of its primary con
## What this folder owns
| Sub-folder / file | Purpose |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `api.server.ts` | Typed `openapi-fetch` client factory — the standard entry point for all backend API calls in server-side load functions and actions |
| `errors.ts` | Mirror of the backend `ErrorCode` enum + `getErrorMessage()` → Paraglide i18n key mapping |
| `types.ts` | Cross-domain TypeScript interfaces |
| `utils.ts` | Pure utility functions (date formatting, sorting, debounce) |
| `utils/monthBuckets.ts` | Pure month-bucket math (boundaries, sequences, gap-fill, year aggregation, axis ticks) shared by the `document/` density chart and the `timeline/` density strip — moved up from `document/timeline.ts` so `timeline/` need not import `document/` |
| `primitives/Sparkline.svelte` | Fixed-series bar sparkline (one bar per value) — used by the timeline density strip |
| `relativeTime.ts` | Human-relative time formatting (`"2 days ago"`) |
| `primitives/` | Generic UI components: `BackButton.svelte`, form inputs, pagination, layout shells |
| `discussion/` | Comment/mention editor shared by `document/` and `geschichte/` |

View File

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

View File

@@ -0,0 +1,221 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { isoToGerman, handleGermanDateInput } from '$lib/shared/utils/date';
import { m } from '$lib/paraglide/messages.js';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
/**
* Generic date + precision input primitive shared by two domains:
* `document/` (via WhoWhenSection) and `timeline/` (via EventForm).
*
* Renders three grid cells — a German `dd.mm.yyyy` text input backed by a hidden
* ISO input, a precision <select>, and a progressively-disclosed end-date input
* shown only for RANGE. Living in `$lib/shared/primitives/` keeps it out of either
* consumer's domain so neither incurs a cross-domain import (eslint boundaries).
*
* Exposed (shared contract — both WhoWhenSection and EventForm depend on it):
* - dateIso, precision, endDateIso — $bindable; the parent's binding IS the
* state (no redundant $state mirror).
* - dateInputName / endDateInputName / precisionInputName — submitted field
* names; defaults match the document form (`metaDatePrecision`), the timeline
* form overrides precisionInputName to `precision`.
* - initialDateIso / suggestedDateIso — seeding inputs (see onMount + $effect).
* - dateTestId / precisionTestId / endDateInnerTestId — forwarded data-testid
* attributes so existing WhoWhenSection selectors survive the extraction.
* - `end-date-region` is always on the OUTER aria-live wrapper of the end block.
*/
let {
dateIso = $bindable(''),
precision = $bindable<DatePrecision>('DAY'),
endDateIso = $bindable(''),
dateInputName = 'documentDate',
endDateInputName = 'metaDateEnd',
precisionInputName = 'metaDatePrecision',
initialDateIso = '',
suggestedDateIso = '',
dateLabel = m.form_label_date(),
dateRequired = true,
dateError = '',
endDateError = '',
onchange = undefined,
dateTestId = undefined,
precisionTestId = undefined,
endDateInnerTestId = undefined
}: {
dateIso?: string;
precision?: DatePrecision;
endDateIso?: string;
dateInputName?: string;
endDateInputName?: string;
precisionInputName?: string;
initialDateIso?: string;
suggestedDateIso?: string;
dateLabel?: string;
dateRequired?: boolean;
/** Server-side date error (e.g. blank required field) wired to the field's aria-invalid. */
dateError?: string;
/** Server-side end-date error (e.g. RANGE without an end date) wired to the end field. */
endDateError?: string;
/** Called on any user edit (date, precision, end-date) — lets a parent track dirtiness. */
onchange?: () => void;
dateTestId?: string;
precisionTestId?: string;
endDateInnerTestId?: string;
} = $props();
const PRECISIONS: { value: DatePrecision; label: () => string }[] = [
{ value: 'DAY', label: m.date_precision_option_day },
{ value: 'MONTH', label: m.date_precision_option_month },
{ value: 'SEASON', label: m.date_precision_option_season },
{ value: 'YEAR', label: m.date_precision_option_year },
{ value: 'RANGE', label: m.date_precision_option_range },
{ value: 'APPROX', label: m.date_precision_option_approx },
{ value: 'UNKNOWN', label: m.date_precision_option_unknown }
];
const showEndDate = $derived(precision === 'RANGE');
// dateDisplay seeds from the bindable's value or initialDateIso once at mount
// and is then user-driven. onMount runs exactly once, so this never stomps
// the parent's dateIso on a later prop change.
let dateDisplay = $state('');
let dateDirty = $state(false);
let endDisplay = $state('');
onMount(() => {
const seed = dateIso || initialDateIso;
if (seed) {
dateDisplay = isoToGerman(seed);
if (!dateIso) dateIso = seed;
}
if (endDateIso) endDisplay = isoToGerman(endDateIso);
});
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
// Either the client-side malformed-date cue or a server-provided required-field
// error marks the field invalid (REQ-011 per-field aria-invalid).
const dateFieldInvalid = $derived(dateInvalid || dateError.length > 0);
// Inline mirror of the server guard (#678). ISO YYYY-MM-DD strings compare
// lexicographically, so no Date object is needed. Server stays the gate —
// this only surfaces the error early; it never disables Save.
const endBeforeStart = $derived(
showEndDate && endDateIso !== '' && dateIso !== '' && endDateIso < dateIso
);
// Either the inline end-before-start cue or a server-provided end-date error
// (e.g. a RANGE event missing its end date) marks the end field invalid.
const endDateFieldInvalid = $derived(endBeforeStart || endDateError.length > 0);
function handleDateInput(e: Event) {
const result = handleGermanDateInput(e);
dateDisplay = result.display;
dateIso = result.iso;
dateDirty = true;
onchange?.();
}
function handleEndDateInput(e: Event) {
const result = handleGermanDateInput(e);
endDisplay = result.display;
endDateIso = result.iso;
onchange?.();
}
$effect(() => {
const suggested = suggestedDateIso;
if (suggested && !untrack(() => dateDirty)) {
dateDisplay = isoToGerman(suggested);
dateIso = suggested;
}
});
</script>
<!-- Datum (required) -->
<div data-testid={dateTestId}>
<label for={dateInputName} class="mb-1 block text-sm font-medium text-ink-2"
>{dateLabel}{#if dateRequired}*{/if}</label
>
<input
id={dateInputName}
type="text"
inputmode="numeric"
value={dateDisplay}
oninput={handleDateInput}
placeholder={m.form_placeholder_date()}
maxlength="10"
aria-required={dateRequired ? 'true' : undefined}
class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm
{dateFieldInvalid
? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500'
: 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
aria-invalid={dateFieldInvalid ? 'true' : undefined}
aria-describedby={dateFieldInvalid ? `${dateInputName}-error` : undefined}
/>
<input type="hidden" name={dateInputName} value={dateIso} />
{#if dateInvalid}
<p id="{dateInputName}-error" class="mt-1 text-xs text-danger">
<span aria-hidden="true"></span>{m.form_date_error()}
</p>
{:else if dateError}
<p id="{dateInputName}-error" class="mt-1 text-xs text-danger">
<span aria-hidden="true"></span>{dateError}
</p>
{/if}
</div>
<!-- Datumsgenauigkeit (precision) -->
<div data-testid={precisionTestId}>
<label for="{dateInputName}Precision" class="mb-1 block text-sm font-medium text-ink-2">
{m.form_label_date_precision()}
</label>
<select
id="{dateInputName}Precision"
name={precisionInputName}
bind:value={precision}
onchange={() => onchange?.()}
class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{#each PRECISIONS as p (p.value)}
<option value={p.value}>{p.label()}</option>
{/each}
</select>
</div>
<!-- Enddatum: progressive disclosure, revealed only for RANGE, announced politely. -->
<div aria-live="polite" data-testid="end-date-region">
{#if showEndDate}
<div data-testid={endDateInnerTestId}>
<label for="{dateInputName}End" class="mb-1 block text-sm font-medium text-ink-2">
{m.form_label_date_end()}
</label>
<input
id="{dateInputName}End"
type="text"
inputmode="numeric"
value={endDisplay}
oninput={handleEndDateInput}
placeholder={m.form_placeholder_date()}
maxlength="10"
aria-invalid={endDateFieldInvalid ? 'true' : undefined}
aria-describedby={endDateFieldInvalid ? `${dateInputName}-end-error` : undefined}
class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm
{endDateFieldInvalid
? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500'
: 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
/>
{#if endBeforeStart}
<!-- Non-colour cue (WCAG 1.4.1): warning glyph + text, not red alone. -->
<p id="{dateInputName}-end-error" class="mt-1 text-xs text-danger">
<span aria-hidden="true"></span>{m.error_invalid_date_range()}
</p>
{:else if endDateError}
<p id="{dateInputName}-end-error" class="mt-1 text-xs text-danger">
<span aria-hidden="true"></span>{endDateError}
</p>
{/if}
</div>
{/if}
</div>
<!-- Off-RANGE submits an empty string so a stale end-date never persists; the
form action converts '' → null before sending the request body. -->
<input type="hidden" name={endDateInputName} value={showEndDate ? endDateIso : ''} />

View File

@@ -0,0 +1,38 @@
<script lang="ts">
/**
* A minimal fixed-series bar sparkline: one bar per value, heights scaled to the
* largest value. Presentational only — callers supply the already-bucketed
* counts. Used by the timeline density strip; reusable by the document chart.
*/
let {
values,
label,
class: className = ''
}: { values: number[]; label?: string; class?: string } = $props();
const max = $derived(Math.max(1, ...values));
// Empty buckets keep a faint floor so the series reads as a continuous axis
// rather than disappearing to nothing.
const MIN_HEIGHT_PCT = 4;
function heightPct(value: number): number {
if (value <= 0) return MIN_HEIGHT_PCT;
return Math.max(MIN_HEIGHT_PCT, (value / max) * 100);
}
</script>
<div
class="flex h-8 items-end gap-[1.5px] {className}"
role="img"
aria-label={label}
aria-hidden={label ? undefined : 'true'}
>
{#each values as value, i (i)}
<div
data-testid="sparkline-bar"
class="flex-1 rounded-[1px] bg-brand-mint"
style="height: {heightPct(value)}%"
></div>
{/each}
</div>

View File

@@ -0,0 +1,28 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import Sparkline from './Sparkline.svelte';
afterEach(() => cleanup());
describe('Sparkline', () => {
it('renders one bar per value', () => {
render(Sparkline, { values: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] });
const bars = document.querySelectorAll('[data-testid="sparkline-bar"]');
expect(bars).toHaveLength(12);
});
it('scales bar heights relative to the largest value', () => {
render(Sparkline, { values: [5, 10, 0] });
const bars = document.querySelectorAll<HTMLElement>('[data-testid="sparkline-bar"]');
const h = (i: number) => parseFloat(bars[i].style.height);
// 10 is the max → tallest; 5 is half of the max's height; 0 is the shortest.
expect(h(1)).toBeGreaterThan(h(0));
expect(h(0)).toBeGreaterThan(h(2));
});
it('exposes an accessible label when provided', () => {
render(Sparkline, { values: [1, 2, 3], label: 'Monatsdichte' });
const img = document.querySelector('[role="img"]');
expect(img?.getAttribute('aria-label')).toBe('Monatsdichte');
});
});

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,267 @@
import { describe, it, expect } from 'vitest';
import {
monthBoundaryFrom,
monthBoundaryTo,
buildMonthSequence,
fillDensityGaps,
aggregateToYears,
selectionBoundaryFrom,
selectionBoundaryTo,
clipBucketsToRange,
tickIndicesFor,
formatTickLabel
} from './monthBuckets';
describe('monthBoundaryFrom', () => {
it('returns the first day of the given month', () => {
expect(monthBoundaryFrom('1915-08')).toBe('1915-08-01');
});
it('handles January', () => {
expect(monthBoundaryFrom('1920-01')).toBe('1920-01-01');
});
});
describe('monthBoundaryTo', () => {
it('returns the last day of a 31-day month', () => {
expect(monthBoundaryTo('1915-08')).toBe('1915-08-31');
});
it('returns the last day of a 30-day month', () => {
expect(monthBoundaryTo('1915-04')).toBe('1915-04-30');
});
it('returns 28 for February in a non-leap year', () => {
expect(monthBoundaryTo('1915-02')).toBe('1915-02-28');
});
it('returns 29 for February in a leap year', () => {
expect(monthBoundaryTo('1916-02')).toBe('1916-02-29');
});
});
describe('buildMonthSequence', () => {
it('returns a single month when min and max are in the same month', () => {
expect(buildMonthSequence('1915-08-03', '1915-08-22')).toEqual(['1915-08']);
});
it('returns months from minDate through maxDate inclusive', () => {
expect(buildMonthSequence('1915-08-03', '1915-11-15')).toEqual([
'1915-08',
'1915-09',
'1915-10',
'1915-11'
]);
});
it('crosses year boundaries correctly', () => {
expect(buildMonthSequence('1915-11-30', '1916-02-01')).toEqual([
'1915-11',
'1915-12',
'1916-01',
'1916-02'
]);
});
it('returns empty array when minDate or maxDate is null', () => {
expect(buildMonthSequence(null, '1915-08-01')).toEqual([]);
expect(buildMonthSequence('1915-08-01', null)).toEqual([]);
expect(buildMonthSequence(null, null)).toEqual([]);
});
});
describe('fillDensityGaps', () => {
it('returns empty array when minDate or maxDate is null', () => {
expect(fillDensityGaps([], null, null)).toEqual([]);
});
it('preserves existing buckets and adds zero-count buckets for missing months', () => {
const buckets = [
{ month: '1915-08', count: 5 },
{ month: '1915-11', count: 2 }
];
const result = fillDensityGaps(buckets, '1915-08-03', '1915-11-30');
expect(result).toEqual([
{ month: '1915-08', count: 5 },
{ month: '1915-09', count: 0 },
{ month: '1915-10', count: 0 },
{ month: '1915-11', count: 2 }
]);
});
it('returns all-zero sequence when buckets array is empty', () => {
const result = fillDensityGaps([], '1915-08-03', '1915-10-15');
expect(result).toEqual([
{ month: '1915-08', count: 0 },
{ month: '1915-09', count: 0 },
{ month: '1915-10', count: 0 }
]);
});
it('keeps results sorted chronologically even when buckets arrive out of order', () => {
const buckets = [
{ month: '1915-10', count: 3 },
{ month: '1915-08', count: 1 }
];
const result = fillDensityGaps(buckets, '1915-08-01', '1915-10-31');
expect(result.map((b) => b.month)).toEqual(['1915-08', '1915-09', '1915-10']);
});
});
describe('aggregateToYears', () => {
it('returns empty array for empty input', () => {
expect(aggregateToYears([])).toEqual([]);
});
it('sums counts within the same year', () => {
const result = aggregateToYears([
{ month: '1915-08', count: 5 },
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 8 }
]);
expect(result).toEqual([{ month: '1915', count: 15 }]);
});
it('produces one bucket per distinct year, sorted chronologically', () => {
const result = aggregateToYears([
{ month: '1916-01', count: 3 },
{ month: '1915-08', count: 5 },
{ month: '1916-04', count: 7 },
{ month: '1914-12', count: 1 }
]);
expect(result).toEqual([
{ month: '1914', count: 1 },
{ month: '1915', count: 5 },
{ month: '1916', count: 10 }
]);
});
});
describe('clipBucketsToRange', () => {
const buckets = [
{ month: '1915-08', count: 5 },
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 8 },
{ month: '1915-11', count: 3 }
];
it('returns the original buckets when range bounds are null', () => {
expect(clipBucketsToRange(buckets, null, null)).toBe(buckets);
});
it('keeps only buckets whose month falls within the range', () => {
expect(clipBucketsToRange(buckets, '1915-09-01', '1915-10-31')).toEqual([
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 8 }
]);
});
it('returns an empty array when the range excludes everything', () => {
expect(clipBucketsToRange(buckets, '1916-01-01', '1916-12-31')).toEqual([]);
});
it('treats partial dates correctly when bounds cross month boundaries', () => {
expect(clipBucketsToRange(buckets, '1915-09-15', '1915-10-15')).toEqual([
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 8 }
]);
});
});
describe('selectionBoundaryFrom / To', () => {
it('handles month labels (YYYY-MM)', () => {
expect(selectionBoundaryFrom('1915-08')).toBe('1915-08-01');
expect(selectionBoundaryTo('1915-08')).toBe('1915-08-31');
});
it('handles year labels (YYYY)', () => {
expect(selectionBoundaryFrom('1915')).toBe('1915-01-01');
expect(selectionBoundaryTo('1915')).toBe('1915-12-31');
});
});
describe('tickIndicesFor', () => {
it('returns no indices for an empty bucket list', () => {
expect(tickIndicesFor([])).toEqual([]);
});
it('picks years divisible by 25 when the year span exceeds 120', () => {
const buckets = Array.from({ length: 150 }, (_, i) => ({
month: String(1875 + i),
count: 1
}));
const ticks = tickIndicesFor(buckets);
const labels = ticks.map((i) => buckets[i].month);
expect(labels).toEqual(['1875', '1900', '1925', '1950', '1975', '2000']);
});
it('picks years divisible by 10 for medium ranges (~50 years)', () => {
const buckets = Array.from({ length: 50 }, (_, i) => ({
month: String(1900 + i),
count: 1
}));
const ticks = tickIndicesFor(buckets);
const labels = ticks.map((i) => buckets[i].month);
expect(labels).toEqual(['1900', '1910', '1920', '1930', '1940']);
});
it('picks January boundaries for long month ranges', () => {
const buckets = [
{ month: '1914-08', count: 1 },
{ month: '1914-09', count: 1 },
{ month: '1914-10', count: 1 },
{ month: '1914-11', count: 1 },
{ month: '1914-12', count: 1 },
{ month: '1915-01', count: 1 },
{ month: '1915-02', count: 1 },
{ month: '1915-03', count: 1 },
{ month: '1915-04', count: 1 },
{ month: '1915-05', count: 1 },
{ month: '1915-06', count: 1 },
{ month: '1915-07', count: 1 },
{ month: '1915-08', count: 1 },
{ month: '1915-09', count: 1 },
{ month: '1915-10', count: 1 },
{ month: '1915-11', count: 1 },
{ month: '1915-12', count: 1 },
{ month: '1916-01', count: 1 },
{ month: '1916-02', count: 1 }
];
const ticks = tickIndicesFor(buckets);
expect(ticks.map((i) => buckets[i].month)).toEqual(['1915-01', '1916-01']);
});
it('falls back to evenly spaced ticks for short month ranges (12 months)', () => {
const buckets = Array.from({ length: 12 }, (_, i) => ({
month: `1905-${String(i + 1).padStart(2, '0')}`,
count: 1
}));
const ticks = tickIndicesFor(buckets);
expect(ticks.length).toBeGreaterThanOrEqual(5);
expect(ticks.length).toBeLessThanOrEqual(7);
expect(ticks[0]).toBe(0);
});
});
describe('formatTickLabel', () => {
it('returns the year string unchanged for year labels', () => {
expect(formatTickLabel('1905', 'en-US')).toBe('1905');
});
it('formats month labels with the year by default', () => {
const result = formatTickLabel('1905-06', 'en-US');
expect(result).toMatch(/Jun/);
expect(result).toMatch(/1905/);
});
it('omits the year when omitYear is true', () => {
const result = formatTickLabel('1905-06', 'en-US', true);
expect(result).toMatch(/Jun/);
expect(result).not.toMatch(/1905/);
});
});

View File

@@ -0,0 +1,163 @@
import type { components } from '$lib/generated/api';
/**
* Pure month-bucket math shared by the document density chart (`lib/document/`)
* and the global timeline strip (`lib/timeline/`). Reuses the generated
* `MonthBucket` schema type so both surfaces stay coupled to the backend shape.
* No I/O, no DOM — relocated here so `lib/timeline/` never imports `lib/document/`.
*/
export type MonthBucket = components['schemas']['MonthBucket'];
export function monthBoundaryFrom(yearMonth: string): string {
return `${yearMonth}-01`;
}
export function monthBoundaryTo(yearMonth: string): string {
const [year, month] = yearMonth.split('-').map(Number);
// Day 0 of `month + 1` rolls back to the last day of `month` — so passing
// `month` (1-indexed) into `Date.UTC(year, month, 0)` lands on the last day
// of that month. Handles 28/29/30/31 and leap years without a lookup table.
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
return `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
}
export function buildMonthSequence(minDate: string | null, maxDate: string | null): string[] {
if (!minDate || !maxDate) return [];
const [minY, minM] = minDate.split('-').map(Number);
const [maxY, maxM] = maxDate.split('-').map(Number);
const sequence: string[] = [];
let year = minY;
let month = minM;
while (year < maxY || (year === maxY && month <= maxM)) {
sequence.push(`${year}-${String(month).padStart(2, '0')}`);
month += 1;
if (month > 12) {
month = 1;
year += 1;
}
}
return sequence;
}
export function fillDensityGaps(
buckets: MonthBucket[],
minDate: string | null,
maxDate: string | null
): MonthBucket[] {
const sequence = buildMonthSequence(minDate, maxDate);
if (sequence.length === 0) return [];
const counts = new Map(buckets.map((b) => [b.month, b.count]));
return sequence.map((month) => ({ month, count: counts.get(month) ?? 0 }));
}
/**
* Returns only the month buckets whose YYYY-MM falls inside the provided
* `[fromInclusive, toInclusive]` ISO date range. When either bound is null the
* input array is returned unchanged. Used by the timeline's zoom-in tool to
* narrow the visible bars without refetching data.
*
* @internal Sole call site is `TimelineDensityFilter.svelte`. Exported so the
* unit suite (`monthBuckets.spec.ts`) can pin the boundary semantics directly.
*/
export function clipBucketsToRange(
buckets: MonthBucket[],
fromInclusive: string | null,
toInclusive: string | null
): MonthBucket[] {
if (!fromInclusive || !toInclusive) return buckets;
const fromMonth = fromInclusive.slice(0, 7);
const toMonth = toInclusive.slice(0, 7);
return buckets.filter((b) => b.month >= fromMonth && b.month <= toMonth);
}
/**
* Aggregates month-granular buckets into one entry per year. Month strings are
* truncated to "YYYY" and counts are summed. Used when the date span is too
* long for month-granular bars to render at a clickable size.
*/
export function aggregateToYears(buckets: MonthBucket[]): MonthBucket[] {
const totals = new Map<string, number>();
for (const b of buckets) {
const year = b.month.slice(0, 4);
totals.set(year, (totals.get(year) ?? 0) + b.count);
}
return Array.from(totals.entries())
.map(([year, count]) => ({ month: year, count }))
.sort((a, b) => a.month.localeCompare(b.month));
}
/**
* Boundary helpers for selection. Accept either "YYYY-MM" (month) or "YYYY"
* (year) and return the matching LocalDate string.
*/
export function selectionBoundaryFrom(label: string): string {
return label.length === 4 ? `${label}-01-01` : `${label}-01`;
}
export function selectionBoundaryTo(label: string): string {
if (label.length === 4) return `${label}-12-31`;
return monthBoundaryTo(label);
}
/**
* Picks bucket indices that should get an X-axis tick label. The strategy adapts
* to whether bars are years or months and how many are visible:
* - Year bars: pick years divisible by a step that scales with range length
* (every 25 yrs for >120 bars, every 20 / 10 / 5 / 1 below).
* - Month bars: prefer January boundaries (year breaks). For ≤18 bars (e.g.
* one year zoomed in to months), fall back to evenly spaced ticks so we
* show ~6 labels even when no January boundary exists.
*/
export function tickIndicesFor(filled: MonthBucket[]): number[] {
if (filled.length === 0) return [];
const isYearMode = filled[0].month.length === 4;
const indices: number[] = [];
if (isYearMode) {
const years = filled.length;
const step =
years > 120 ? 25 : years > 60 ? 20 : years > 30 ? 10 : years > 12 ? 5 : years > 6 ? 2 : 1;
for (let i = 0; i < filled.length; i++) {
const year = parseInt(filled[i].month, 10);
if (year % step === 0) indices.push(i);
}
return indices;
}
if (filled.length <= 18) {
const step = Math.max(1, Math.round(filled.length / 6));
for (let i = 0; i < filled.length; i += step) indices.push(i);
return indices;
}
// Long month range — pick January boundaries (year breaks).
for (let i = 0; i < filled.length; i++) {
if (filled[i].month.endsWith('-01')) indices.push(i);
}
// Fallback if there's no January in the visible range (rare): even spacing.
if (indices.length === 0) {
const step = Math.max(1, Math.round(filled.length / 6));
for (let i = 0; i < filled.length; i += step) indices.push(i);
}
return indices;
}
/**
* Formats a bucket month label ("YYYY" or "YYYY-MM") for the X-axis. When
* `omitYear` is true the year is dropped so a 12-month zoomed view reads as
* "Jan", "Feb", … without repetition.
*/
export function formatTickLabel(label: string, locale?: string, omitYear = false): string {
if (label.length === 4) return label;
const [yearStr, monthStr] = label.split('-');
const date = new Date(parseInt(yearStr, 10), parseInt(monthStr, 10) - 1, 1);
const opts: Intl.DateTimeFormatOptions = omitYear
? { month: 'short' }
: { month: 'short', year: 'numeric' };
return new Intl.DateTimeFormat(locale, opts).format(date);
}

View File

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

View File

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

View File

@@ -0,0 +1,65 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { getAccentConfig } from './eventCardConfig';
import { timelineDateLabel } from './dateLabel';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/**
* Centered axis pill for a derived life-event or a curated PERSONAL event
* (REQ-007/008). The glyph is wrapped aria-hidden with an sr-only label sibling
* (REQ-018). An edit affordance shows only for a curated event with an eventId
* (never derived, never null — REQ-008).
*/
let { entry }: { entry: TimelineEntryDTO } = $props();
const config = $derived(getAccentConfig(entry));
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
// Provenance reads off entry.derived (not the accent): a derived life-event is
// "abgeleitet", a curated PERSONAL event is "kuratiert" (REQ-007).
const provenance = $derived(
entry.derived ? m.timeline_provenance_derived() : m.timeline_provenance_curated()
);
// Provenance always shows; the date is an optional prefix so an undated event
// still reads "abgeleitet"/"kuratiert" (REQ-007).
const subtitle = $derived(dateLabel ? `${dateLabel} · ${provenance}` : provenance);
const canEdit = $derived(!entry.derived && entry.eventId != null);
</script>
<div class="flex justify-center">
<div
class="inline-flex items-center gap-2 rounded-full bg-surface px-3 py-1 shadow-sm {config.accent ===
'curated'
? 'border-2 border-brand-mint'
: 'border border-brand-navy'}"
>
<span
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full {config.accent ===
'curated'
? 'bg-brand-mint text-brand-navy'
: 'bg-brand-navy text-brand-mint'}"
>
<span aria-hidden="true">{config.glyph}</span>
<span class="sr-only">{config.label}</span>
</span>
<span class="text-left">
{#if entry.title}
<span class="block font-serif text-sm font-bold whitespace-pre-line text-brand-navy"
>{entry.title}</span
>
{/if}
<span class="block font-sans text-xs text-ink-3">{subtitle}</span>
</span>
{#if canEdit}
<a
data-testid="event-edit"
href="/zeitstrahl/events/{entry.eventId}/edit"
class="ml-1 rounded-sm px-1 font-sans text-xs text-ink-3 hover:text-brand-navy focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
>
<span aria-hidden="true"></span>
<span class="sr-only">{m.btn_edit()}</span>
</a>
{/if}
</div>
</div>

View File

@@ -0,0 +1,142 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import * as m from '$lib/paraglide/messages.js';
import EventPill from './EventPill.svelte';
import { timelineDateLabel } from './dateLabel';
import { makeEntry } from './test-factories';
afterEach(() => cleanup());
const EVENT_ID = '33333333-3333-3333-3333-333333333333';
function derived(derivedType: 'BIRTH' | 'DEATH' | 'MARRIAGE', title: string) {
return makeEntry({
kind: 'EVENT',
derived: true,
derivedType,
title,
senderName: '',
receiverName: '',
precision: 'YEAR',
eventDate: '1914-01-01',
documentId: undefined
});
}
describe('EventPill', () => {
it('renders a derived marriage as ⚭ + "Heirat" + title (REQ-007)', () => {
render(EventPill, { entry: derived('MARRIAGE', 'Heirat: Karl & Elfriede') });
expect(document.body.textContent).toContain('⚭');
expect(document.body.textContent).toContain('Heirat');
expect(document.body.textContent).toContain('Heirat: Karl & Elfriede');
});
it('renders a derived birth as * + "Geburt" (REQ-007)', () => {
render(EventPill, { entry: derived('BIRTH', 'Geburt: Hans') });
expect(document.body.textContent).toContain('*');
expect(document.body.textContent).toContain('Geburt');
});
it('renders a derived death as † + "Tod" (REQ-007)', () => {
render(EventPill, { entry: derived('DEATH', 'Tod: Karl') });
expect(document.body.textContent).toContain('†');
expect(document.body.textContent).toContain('Tod');
});
it('wraps the glyph aria-hidden with an sr-only label sibling (REQ-018)', () => {
render(EventPill, { entry: derived('BIRTH', 'Geburt: Hans') });
const hidden = document.querySelector('[aria-hidden="true"]');
expect(hidden?.textContent).toBe('*');
const srOnly = document.querySelector('.sr-only');
expect(srOnly?.textContent).toBe('Geburt');
});
it('shows an edit affordance for a curated PERSONAL event with an eventId (REQ-008)', () => {
render(EventPill, {
entry: makeEntry({
kind: 'EVENT',
derived: false,
type: 'PERSONAL',
eventId: EVENT_ID,
title: 'Auswanderung',
senderName: '',
receiverName: '',
documentId: undefined
})
});
const edit = document.querySelector('[data-testid="event-edit"]') as HTMLAnchorElement | null;
expect(edit).not.toBeNull();
expect(edit?.getAttribute('href')).toContain(EVENT_ID);
});
it('shows no edit affordance when eventId is null (REQ-008)', () => {
render(EventPill, {
entry: makeEntry({
kind: 'EVENT',
derived: false,
type: 'PERSONAL',
eventId: undefined,
title: 'Auswanderung',
senderName: '',
receiverName: '',
documentId: undefined
})
});
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
});
it('shows no edit affordance for a derived event (REQ-008)', () => {
render(EventPill, { entry: derived('MARRIAGE', 'Heirat') });
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
});
it('appends the "abgeleitet" provenance token to a derived pill subtitle (REQ-007)', () => {
const entry = derived('BIRTH', 'Geburt: Hans');
const date = timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd);
render(EventPill, { entry });
expect(document.body.textContent).toContain(`${date} · ${m.timeline_provenance_derived()}`);
});
it('appends the "kuratiert" provenance token to a curated PERSONAL pill subtitle (REQ-007)', () => {
const entry = makeEntry({
kind: 'EVENT',
derived: false,
type: 'PERSONAL',
eventId: EVENT_ID,
title: 'Auswanderung',
senderName: '',
receiverName: '',
precision: 'YEAR',
eventDate: '1924-01-01',
documentId: undefined
});
const date = timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd);
render(EventPill, { entry });
expect(document.body.textContent).toContain(`${date} · ${m.timeline_provenance_curated()}`);
});
it('never shows the spec-sheet-only "persönlich"/"SEASON" tokens (REQ-007)', () => {
render(EventPill, { entry: derived('BIRTH', 'Geburt: Hans') });
expect(document.body.textContent).not.toContain('persönlich');
expect(document.body.textContent).not.toContain('SEASON');
});
it('still shows the provenance token when the event has no date label (REQ-007)', () => {
// An undated / UNKNOWN-precision event (e.g. in the undated bucket) yields a
// null dateLabel; provenance must not be gated behind the date.
const entry = makeEntry({
kind: 'EVENT',
derived: true,
derivedType: 'BIRTH',
title: 'Geburt: Hans',
senderName: '',
receiverName: '',
precision: 'UNKNOWN',
eventDate: undefined,
documentId: undefined
});
expect(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd)).toBeNull();
render(EventPill, { entry });
expect(document.body.textContent).toContain(m.timeline_provenance_derived());
});
});

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,20 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
/**
* A folded run of fully-empty interior years (REQ-015), rendered as a thin
* dashed span so the scroll stays oriented. Collapses to a single year when the
* run has length 1.
*/
let { from, to }: { from: number; to: number } = $props();
const yearLabel = $derived(from === to ? `${from}` : `${from}${to}`);
</script>
<div
class="mx-auto my-2 flex max-w-md items-center gap-2 rounded-full border border-dashed border-line bg-canvas px-4 py-1 font-sans text-xs text-ink-3"
>
<span class="h-px flex-1 bg-line"></span>
<span><span class="font-serif text-ink-2">{yearLabel}</span> · {m.timeline_gap_empty()}</span>
<span class="h-px flex-1 bg-line"></span>
</div>

View File

@@ -0,0 +1,20 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import GapSpan from './GapSpan.svelte';
afterEach(() => cleanup());
describe('GapSpan', () => {
it('renders a multi-year empty run as "{from}{to} · keine Einträge" (REQ-015)', () => {
render(GapSpan, { from: 1910, to: 1914 });
expect(document.body.textContent).toContain('19101914');
expect(document.body.textContent).toContain('keine Einträge');
});
it('renders a single empty year as "{year} · keine Einträge" (REQ-015)', () => {
render(GapSpan, { from: 1912, to: 1912 });
expect(document.body.textContent).toContain('1912');
expect(document.body.textContent).not.toContain('19121912');
expect(document.body.textContent).toContain('keine Einträge');
});
});

View File

@@ -0,0 +1,12 @@
<script lang="ts">
/**
* A decorative glyph paired with a screen-reader-only text label — the
* non-color accessibility cue used across timeline cards (e.g. the ✉ on a
* letter title or letter-count). The glyph is `aria-hidden`; the label carries
* the meaning for assistive tech.
*/
let { glyph, label }: { glyph: string; label: string } = $props();
</script>
<span aria-hidden="true">{glyph}</span>
<span class="sr-only">{label}</span>

View File

@@ -0,0 +1,15 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import GlyphLabel from './GlyphLabel.svelte';
afterEach(() => cleanup());
describe('GlyphLabel', () => {
it('renders the glyph aria-hidden with an sr-only label sibling', () => {
render(GlyphLabel, { glyph: '✉', label: 'Brief' });
const hidden = document.querySelector('[aria-hidden="true"]');
expect(hidden?.textContent).toBe('✉');
const srOnly = document.querySelector('.sr-only');
expect(srOnly?.textContent).toBe('Brief');
});
});

View File

@@ -0,0 +1,49 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { timelineDateLabel } from './dateLabel';
import GlyphLabel from './GlyphLabel.svelte';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/**
* A single archive letter on the timeline: sender → receiver, title, and a
* precision-aware date chip, linking to the document. Names/titles are
* OCR/import-derived — rendered via default `{...}` escaping with
* `whitespace-pre-line` for line breaks (REQ-021); never the raw-HTML directive.
*/
let { entry }: { entry: TimelineEntryDTO } = $props();
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
const sender = $derived(entry.senderName === '' ? m.timeline_unknown_person() : entry.senderName);
const receiver = $derived(
entry.receiverName === '' ? m.timeline_unknown_person() : entry.receiverName
);
</script>
<!-- Box layout inline (not just utility classes) so the 44px touch target holds
even before the stylesheet loads — an <a> is inline by default and would
ignore min-height otherwise. WCAG 2.5.5 (REQ-020). -->
<a
href="/documents/{entry.documentId}"
style="display: flex; flex-direction: column; justify-content: center; min-height: 44px"
class="rounded-sm border border-l-[3px] border-line border-l-brand-mint bg-surface px-3 py-2 shadow-sm transition-colors hover:border-brand-mint focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
>
{#if entry.title}
<!-- ✉ + sr-only label are static chrome rendered as sibling nodes, NEVER
interpolated into the escaped user title; the title keeps its own
pre-line span for multi-line OCR text (REQ-008/016/021). -->
<span class="font-serif text-sm font-bold break-words text-ink">
<GlyphLabel glyph="✉" label={m.timeline_letter_glyph_label()} />
<span class="whitespace-pre-line">{entry.title}</span>
</span>
{/if}
<span class="mt-0.5 font-sans text-xs break-words text-ink-3">
<span class="font-serif whitespace-pre-line">{sender}</span>
<span aria-hidden="true"></span>
<span class="font-serif whitespace-pre-line">{receiver}</span>
{#if dateLabel}
<span data-testid="letter-date"> · {dateLabel}</span>
{/if}
</span>
</a>

View File

@@ -0,0 +1,89 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import * as m from '$lib/paraglide/messages.js';
import LetterCard from './LetterCard.svelte';
import { timelineDateLabel } from './dateLabel';
import { makeEntry } from './test-factories';
afterEach(() => cleanup());
const DOC_ID = '22222222-2222-2222-2222-222222222222';
describe('LetterCard', () => {
it('renders sender, receiver, and title', () => {
render(LetterCard, {
entry: makeEntry({ senderName: 'Karl', receiverName: 'Elfriede', title: 'Feldpost' })
});
expect(document.body.textContent).toContain('Karl');
expect(document.body.textContent).toContain('Elfriede');
expect(document.body.textContent).toContain('Feldpost');
});
it('renders the precision date exactly as timelineDateLabel returns (REQ-013)', () => {
const entry = makeEntry({ eventDate: '1915-06-15', precision: 'MONTH' });
const expected = timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd);
expect(expected).toBeTruthy();
render(LetterCard, { entry });
expect(document.body.textContent).toContain(expected as string);
});
it('renders no date chip when timelineDateLabel returns null (REQ-013)', () => {
const entry = makeEntry({ precision: 'UNKNOWN', eventDate: undefined });
render(LetterCard, { entry });
const chip = document.querySelector('[data-testid="letter-date"]');
expect(chip).toBeNull();
});
it('shows "Unbekannt" for an empty sender, never a bare arrow (REQ-014)', () => {
render(LetterCard, { entry: makeEntry({ senderName: '', receiverName: 'Elfriede' }) });
expect(document.body.textContent).toContain('Unbekannt');
});
it('shows "Unbekannt" for an empty receiver (REQ-014)', () => {
render(LetterCard, { entry: makeEntry({ senderName: 'Karl', receiverName: '' }) });
expect(document.body.textContent).toContain('Unbekannt');
});
it('links to exactly /documents/{documentId} with no target (REQ-023)', () => {
render(LetterCard, { entry: makeEntry({ documentId: DOC_ID }) });
const link = document.querySelector('a') as HTMLAnchorElement;
expect(link.getAttribute('href')).toBe(`/documents/${DOC_ID}`);
expect(link.hasAttribute('target')).toBe(false);
});
it('has a touch target of at least 44px (REQ-020)', () => {
render(LetterCard, { entry: makeEntry() });
const link = document.querySelector('a') as HTMLAnchorElement;
expect(link.getBoundingClientRect().height).toBeGreaterThanOrEqual(44);
});
it('prefixes a present title with an aria-hidden ✉ and an sr-only "Brief" label (REQ-008)', () => {
render(LetterCard, { entry: makeEntry({ title: 'Brief aus Stettin', documentId: DOC_ID }) });
const hidden = document.querySelector('[aria-hidden="true"]');
expect(hidden?.textContent).toContain('✉');
const srOnly = document.querySelector('.sr-only');
expect(srOnly?.textContent).toBe(m.timeline_letter_glyph_label());
// The glyph is decorative chrome — the document link is unchanged.
const link = document.querySelector('a') as HTMLAnchorElement;
expect(link.getAttribute('href')).toBe(`/documents/${DOC_ID}`);
});
it('renders no ✉ glyph and no "Brief" label when the title is empty (REQ-016)', () => {
render(LetterCard, {
entry: makeEntry({ title: '', senderName: 'Karl', receiverName: 'Elfriede' })
});
expect(document.body.textContent).not.toContain('✉');
expect(document.querySelector('.sr-only')).toBeNull();
// The row still shows sender → receiver and the date.
expect(document.body.textContent).toContain('Karl');
expect(document.body.textContent).toContain('Elfriede');
expect(document.querySelector('[data-testid="letter-date"]')).not.toBeNull();
});
it('renders an HTML-bearing title verbatim as text, never as markup (security, REQ-021)', () => {
const evil = '<script>alert(1)</script>';
render(LetterCard, { entry: makeEntry({ title: evil }) });
expect(document.body.textContent).toContain(evil);
expect(document.querySelector('a script')).toBeNull();
});
});

View File

@@ -0,0 +1,129 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import YearBand from './YearBand.svelte';
import GapSpan from './GapSpan.svelte';
import LetterCard from './LetterCard.svelte';
import EventPill from './EventPill.svelte';
import WorldBand from './WorldBand.svelte';
import { entryKey } from './entryKey';
import type { components } from '$lib/generated/api';
type TimelineDTO = components['schemas']['TimelineDTO'];
type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
/**
* Orchestrates the global timeline (REQ-001/003). Renders the year bands the DTO
* delivers in order — never re-sorting — interleaving a folded GapSpan for each
* interior run of empty years (REQ-015), then the undated bucket (REQ-016). An
* empty timeline shows a calm message (REQ-017). `personId` is a declared seam
* for the per-person rail (issue #10) and is undefined here; it is not passed to
* leaf cards (REQ-025). Owns no <main> — the layout does.
*/
let { timeline, personId = undefined }: { timeline: TimelineDTO; personId?: string } = $props();
type Row = { t: 'band'; year: TimelineYearDTO } | { t: 'gap'; from: number; to: number };
const rows = $derived.by<Row[]>(() => {
const out: Row[] = [];
const years = timeline.years;
for (let i = 0; i < years.length; i++) {
if (i > 0) {
const prev = years[i - 1].year;
const cur = years[i].year;
if (cur - prev > 1) out.push({ t: 'gap', from: prev + 1, to: cur - 1 });
}
out.push({ t: 'band', year: years[i] });
}
return out;
});
const isEmpty = $derived(timeline.years.length === 0 && timeline.undated.length === 0);
</script>
{#if isEmpty}
<p class="py-12 text-center font-serif text-base text-ink-2">{m.timeline_empty_state()}</p>
{:else}
<!-- personId is a declared seam for the per-person Lebensweg rail (issue #10);
undefined in the global view, surfaced only on the root, never passed to
leaf cards (REQ-025). -->
<ol class="timeline-axis relative mx-auto max-w-3xl" data-person-id={personId}>
{#each rows as row (row.t === 'band' ? `band-${row.year.year}` : `gap-${row.from}`)}
<li>
{#if row.t === 'band'}
<YearBand year={row.year} />
{:else}
<GapSpan from={row.from} to={row.to} />
{/if}
</li>
{/each}
</ol>
{#if timeline.undated.length > 0}
<section
data-testid="undated-section"
class="mx-auto mt-8 max-w-3xl rounded-sm border border-dashed border-line bg-surface p-4"
>
<h2 class="mb-3 font-serif text-sm font-bold text-ink-2">
{m.timeline_undated_section()} · {timeline.undated.length}
</h2>
<ul class="space-y-2">
<!-- The undated bucket is filtered from ALL entries, so it can hold
events as well as letters. Dispatch on kind/type exactly like
YearBand — an event rendered as a LetterCard would link to
/documents/undefined and read "Unknown → Unknown" (REQ-007/008/009). -->
{#each timeline.undated as entry (entryKey(entry))}
<li>
{#if entry.kind === 'EVENT'}
{#if entry.type === 'HISTORICAL'}
<WorldBand entry={entry} />
{:else}
<EventPill entry={entry} />
{/if}
{:else}
<LetterCard entry={entry} />
{/if}
</li>
{/each}
</ul>
</section>
{/if}
{/if}
<style>
/* Establish a stacking context so the spine (z-index: -1) sits behind the
in-flow cards/pills/strips but still in front of the canvas — the line is
always background; the badges, dots and markers ride on top of it.
--spine-x is the single source of truth for the spine's X position; the
year-node and connector dots in YearBand consume it via inheritance so the
markers can never desync from the line. */
.timeline-axis {
--spine-x: 0.5rem;
position: relative;
z-index: 0;
}
/* Phone (< 1024px): a single left-anchored spine. Desktop (≥ 1024px): a
centered spine the bands' alternating cards sit on either side of. The
spine is decorative — the chronology lives in the <ol> DOM order. */
.timeline-axis::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: var(--spine-x);
width: 2px;
z-index: -1;
/* Three-stop life-thread: mint → navy → slate. Slate lives only as
--c-tag-slate (there is no --palette-slate). REQ-006/013. */
background: linear-gradient(var(--palette-mint), var(--palette-navy), var(--c-tag-slate));
}
@media (min-width: 1024px) {
.timeline-axis {
--spine-x: 50%;
}
.timeline-axis::before {
transform: translateX(-50%);
}
}
</style>

View File

@@ -0,0 +1,279 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import * as m from '$lib/paraglide/messages.js';
import TimelineView from './TimelineView.svelte';
import { makeEntry, makeYear, makeTimelineDTO } from './test-factories';
afterEach(() => cleanup());
describe('TimelineView', () => {
it('shows the empty state and no ol when there are no years and no undated (REQ-017)', () => {
render(TimelineView, { timeline: makeTimelineDTO() });
expect(document.body.textContent).toContain('Noch keine Ereignisse.');
expect(document.querySelector('ol')).toBeNull();
expect(document.querySelector('section')).toBeNull();
});
it('renders the timeline as a single <ol> with each band a <section>, ascending (REQ-006)', () => {
render(TimelineView, {
timeline: makeTimelineDTO({
years: [
makeYear(1914, [makeEntry({ documentId: 'a' })]),
makeYear(1916, [makeEntry({ documentId: 'b' })])
]
})
});
expect(document.querySelectorAll('ol')).toHaveLength(1);
const headings = Array.from(document.querySelectorAll('section h2')).map((h) => h.textContent);
expect(headings.some((t) => t?.includes('1914'))).toBe(true);
const order = headings.map((t) => t?.trim());
expect(order.indexOf('1914')).toBeLessThan(order.indexOf('1916'));
});
it('folds an interior run of empty years into one GapSpan (REQ-015)', () => {
render(TimelineView, {
timeline: makeTimelineDTO({
years: [
makeYear(1909, [makeEntry({ documentId: 'a' })]),
makeYear(1915, [makeEntry({ documentId: 'b' })])
]
})
});
expect(document.body.textContent).toContain('19101914');
expect(document.body.textContent).toContain('keine Einträge');
});
it('folds a single empty interior year as a single year (REQ-015)', () => {
render(TimelineView, {
timeline: makeTimelineDTO({
years: [
makeYear(1911, [makeEntry({ documentId: 'a' })]),
makeYear(1913, [makeEntry({ documentId: 'b' })])
]
})
});
expect(document.body.textContent).toContain('1912');
expect(document.body.textContent).not.toContain('19121912');
});
it('renders an "Ohne Datum" section when undated is non-empty (REQ-016)', () => {
render(TimelineView, {
timeline: makeTimelineDTO({
years: [makeYear(1914, [makeEntry({ documentId: 'a' })])],
undated: [makeEntry({ precision: 'UNKNOWN', eventDate: undefined, documentId: 'u1' })]
})
});
expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
expect(document.body.textContent).toContain('Ohne Datum');
});
it('omits the "Ohne Datum" section from the DOM when undated is empty (REQ-016)', () => {
render(TimelineView, {
timeline: makeTimelineDTO({ years: [makeYear(1914, [makeEntry({ documentId: 'a' })])] })
});
expect(document.querySelector('[data-testid="undated-section"]')).toBeNull();
});
it('frames the undated section with a dashed border and shows the count (REQ-012)', () => {
const undated = Array.from({ length: 11 }, (_, i) =>
makeEntry({ precision: 'UNKNOWN', eventDate: undefined, documentId: `u-${i}` })
);
render(TimelineView, { timeline: makeTimelineDTO({ undated }) });
const section = document.querySelector('[data-testid="undated-section"]') as HTMLElement;
expect(section).not.toBeNull();
expect(section.classList.contains('border-dashed')).toBe(true);
const h2 = section.querySelector('h2');
expect(h2?.textContent).toContain(m.timeline_undated_section());
expect(h2?.textContent).toContain('11');
});
it('renders all years and undated entries with personId undefined, no filtering (REQ-025)', () => {
render(TimelineView, {
timeline: makeTimelineDTO({
years: [
makeYear(1914, [makeEntry({ documentId: 'a' })]),
makeYear(1915, [makeEntry({ documentId: 'b' })])
],
undated: [makeEntry({ precision: 'UNKNOWN', eventDate: undefined, documentId: 'u1' })]
}),
personId: undefined
});
// Two year bands inside the <ol>, plus the separate undated section.
expect(document.querySelectorAll('ol section h2')).toHaveLength(2);
expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
});
it('renders an undated curated EVENT as a pill, not a broken letter card (REQ-008/016)', () => {
render(TimelineView, {
timeline: makeTimelineDTO({
undated: [
makeEntry({
kind: 'EVENT',
type: 'PERSONAL',
derived: false,
eventId: 'e1',
precision: 'UNKNOWN',
eventDate: undefined,
title: 'Auswanderung',
senderName: '',
receiverName: '',
documentId: undefined
})
]
})
});
// The event renders inside the undated section…
expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
expect(document.body.textContent).toContain('Auswanderung');
// …as an EventPill (its edit affordance), never as a letter card linking
// to /documents/undefined with "Unbekannt → Unbekannt".
expect(document.querySelector('[data-testid="event-edit"]')).not.toBeNull();
expect(document.querySelector('a[href="/documents/undefined"]')).toBeNull();
expect(document.body.textContent).not.toContain('Unbekannt');
});
it('renders an undated HISTORICAL EVENT as a world band, not a letter card (REQ-009/016)', () => {
render(TimelineView, {
timeline: makeTimelineDTO({
undated: [
makeEntry({
kind: 'EVENT',
type: 'HISTORICAL',
derived: false,
precision: 'UNKNOWN',
eventDate: undefined,
title: 'Weltwirtschaftskrise',
senderName: '',
receiverName: '',
documentId: undefined
})
]
})
});
expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
expect(document.body.textContent).toContain('Weltwirtschaftskrise');
// HISTORICAL → WorldBand carries the sr-only "Weltgeschehen" cue (REQ-018),
// not a broken document link.
expect(document.body.textContent).toContain('Weltgeschehen');
expect(document.querySelector('a[href="/documents/undefined"]')).toBeNull();
});
it('still renders an undated LETTER as a letter card (REQ-016)', () => {
render(TimelineView, {
timeline: makeTimelineDTO({
undated: [makeEntry({ precision: 'UNKNOWN', eventDate: undefined, documentId: 'u1' })]
})
});
expect(document.querySelector('a[href="/documents/u1"]')).not.toBeNull();
});
it('renders two derived events in one band without key collision (no-double-null-key)', () => {
const a = makeEntry({
kind: 'EVENT',
derived: true,
derivedType: 'BIRTH',
title: 'Geburt: Anna',
senderName: '',
receiverName: '',
documentId: undefined,
eventId: undefined,
linkedPersonIds: ['p1']
});
const b = makeEntry({
kind: 'EVENT',
derived: true,
derivedType: 'BIRTH',
title: 'Geburt: Bertha',
senderName: '',
receiverName: '',
documentId: undefined,
eventId: undefined,
linkedPersonIds: ['p2']
});
render(TimelineView, { timeline: makeTimelineDTO({ years: [makeYear(1915, [a, b])] }) });
expect(document.body.textContent).toContain('Geburt: Anna');
expect(document.body.textContent).toContain('Geburt: Bertha');
});
it('shows the redundant non-color cue label for each layer (REQ-018)', () => {
render(TimelineView, {
timeline: makeTimelineDTO({
years: [
makeYear(1914, [
makeEntry({
kind: 'EVENT',
derived: true,
derivedType: 'BIRTH',
title: 'Geburt: Hans',
senderName: '',
receiverName: '',
documentId: undefined
}),
makeEntry({
kind: 'EVENT',
derived: false,
type: 'PERSONAL',
eventId: 'e1',
title: 'Auswanderung',
senderName: '',
receiverName: '',
documentId: undefined
}),
makeEntry({
kind: 'EVENT',
derived: false,
type: 'HISTORICAL',
precision: 'RANGE',
eventDate: '1914-01-01',
eventDateEnd: '1918-12-31',
title: 'Erster Weltkrieg',
senderName: '',
receiverName: '',
documentId: undefined
})
])
]
})
});
expect(document.body.textContent).toContain('Weltgeschehen');
expect(document.body.textContent).toContain('Familie');
expect(document.body.textContent).toContain('Geburt');
});
it('paints the axis with a three-stop mint→navy→slate gradient (REQ-006)', () => {
// The palette tokens live in layout.css, which component tests don't load,
// so define exactly the three the gradient must reference; an undefined
// fourth token would invalidate the declaration and yield "none".
const root = document.documentElement;
root.style.setProperty('--palette-mint', '#a1dcd8');
root.style.setProperty('--palette-navy', '#012851');
root.style.setProperty('--c-tag-slate', '#607080');
try {
render(TimelineView, {
timeline: makeTimelineDTO({ years: [makeYear(1914, [makeEntry()])] })
});
const axis = document.querySelector('.timeline-axis') as HTMLElement;
expect(axis).not.toBeNull();
const before = getComputedStyle(axis, '::before');
const bg = before.backgroundImage;
expect(bg).toContain('gradient');
// three colour stops: mint, navy, slate
expect((bg.match(/rgb/g) ?? []).length).toBe(3);
// the spine is always background: behind the in-flow cards/pills/strips.
expect(before.zIndex).toBe('-1');
} finally {
root.style.removeProperty('--palette-mint');
root.style.removeProperty('--palette-navy');
root.style.removeProperty('--c-tag-slate');
}
});
it('places consecutive letter cards on alternating sides (REQ-004 surrogate)', () => {
const letters = Array.from({ length: 4 }, (_, i) => makeEntry({ documentId: `d${i}` }));
render(TimelineView, { timeline: makeTimelineDTO({ years: [makeYear(1909, letters)] }) });
const sides = Array.from(document.querySelectorAll('.letter-row')).map((el) =>
el.getAttribute('data-side')
);
expect(sides).toEqual(['left', 'right', 'left', 'right']);
});
});

View File

@@ -0,0 +1,49 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { getAccentConfig } from './eventCardConfig';
import { timelineDateLabel } from './dateLabel';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/**
* Full-width muted band for a HISTORICAL event, laid across the axis as context
* (REQ-009). A RANGE carries a visible span pill ("19141918") with a Zeitraum
* aria-label; a RANGE with no end degrades to the start year, no pill (REQ-010).
* The glyph is a redundant non-color cue with an sr-only label (REQ-018); text
* uses text-ink-2 to stay AA in both themes (REQ-019).
*/
let { entry }: { entry: TimelineEntryDTO } = $props();
const config = $derived(getAccentConfig(entry));
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
const fromYear = $derived(entry.eventDate ? entry.eventDate.slice(0, 4) : null);
const toYear = $derived(entry.eventDateEnd ? entry.eventDateEnd.slice(0, 4) : null);
const showSpan = $derived(entry.precision === 'RANGE' && fromYear != null && toYear != null);
const dateText = $derived(showSpan ? null : entry.precision === 'RANGE' ? fromYear : dateLabel);
// Every WorldBand is a HISTORICAL band, so the visible "historisch" register
// always trails the subtitle as plain text — never a second pill (REQ-009).
const historical = $derived(m.timeline_layer_historical_suffix());
</script>
<div class="my-3 border-y border-line bg-canvas px-4 py-2 text-center">
<span class="font-serif text-sm text-ink-2 italic">
<span aria-hidden="true" style="color: var(--c-tag-slate)">{config.glyph}</span>
<span class="sr-only">{config.label}</span>
{entry.title}
</span>
{#if showSpan && fromYear && toYear}
<span
data-testid="world-range"
class="ml-2 inline-block rounded-full border border-line px-2 py-0.5 font-sans text-xs text-ink-2"
aria-label={m.timeline_range_aria({ from: fromYear, to: toYear })}
>
{fromYear}{toYear}
</span>
{:else if dateText}
<span class="ml-2 font-sans text-xs text-ink-3">{dateText}</span>
{/if}
<!-- Single trailing "· historisch" register, after the title and any
span pill / date — one render site, consistent separator (REQ-009). -->
<span class="ml-2 font-sans text-xs text-ink-3">· {historical}</span>
</div>

View File

@@ -0,0 +1,76 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import * as m from '$lib/paraglide/messages.js';
import WorldBand from './WorldBand.svelte';
import { makeEntry } from './test-factories';
afterEach(() => cleanup());
function historical(overrides = {}) {
return makeEntry({
kind: 'EVENT',
derived: false,
type: 'HISTORICAL',
title: 'Erster Weltkrieg',
senderName: '',
receiverName: '',
precision: 'RANGE',
eventDate: '1914-01-01',
eventDateEnd: '1918-12-31',
documentId: undefined,
...overrides
});
}
describe('WorldBand', () => {
it('renders the historical title with the world glyph + "Weltgeschehen" cue (REQ-018)', () => {
render(WorldBand, { entry: historical() });
expect(document.body.textContent).toContain('Erster Weltkrieg');
const hidden = document.querySelector('[aria-hidden="true"]');
expect(hidden?.textContent).toBe('◍');
const srOnly = document.querySelector('.sr-only');
expect(srOnly?.textContent).toBe('Weltgeschehen');
});
it('renders a RANGE span pill 19141918 with a Zeitraum aria-label (REQ-009)', () => {
render(WorldBand, { entry: historical() });
const pill = document.querySelector('[data-testid="world-range"]');
expect(pill).not.toBeNull();
expect(pill?.textContent).toContain('19141918');
expect(pill?.getAttribute('aria-label')).toBe('Zeitraum: 1914 bis 1918');
});
it('degrades a RANGE with no end to the start year, no span pill, no crash (REQ-010)', () => {
render(WorldBand, { entry: historical({ eventDateEnd: undefined }) });
expect(document.querySelector('[data-testid="world-range"]')).toBeNull();
expect(document.body.textContent).toContain('Erster Weltkrieg');
expect(document.body.textContent).toContain('1914');
});
it('appends the inline "· historisch" descriptor to a non-RANGE band (#833 REQ-009)', () => {
render(WorldBand, {
entry: historical({ precision: 'APPROX', eventDate: '1923-01-01', eventDateEnd: undefined })
});
expect(document.querySelector('[data-testid="world-range"]')).toBeNull();
expect(document.body.textContent).toContain(m.timeline_layer_historical_suffix());
});
it('shows "· historisch" with a leading separator even when the band has no date (#833 REQ-009)', () => {
render(WorldBand, {
entry: historical({ precision: 'UNKNOWN', eventDate: undefined, eventDateEnd: undefined })
});
expect(document.querySelector('[data-testid="world-range"]')).toBeNull();
expect(document.body.textContent).toContain(`· ${m.timeline_layer_historical_suffix()}`);
});
it('follows the RANGE span pill with inline "· historisch" text, not a second pill (#833 REQ-009)', () => {
render(WorldBand, { entry: historical() });
const pill = document.querySelector('[data-testid="world-range"]');
expect(pill).not.toBeNull();
// The #779 span pill + its Zeitraum aria-label are unchanged.
expect(pill?.getAttribute('aria-label')).toBe('Zeitraum: 1914 bis 1918');
// The descriptor is plain text outside the pill, not a second styled pill.
expect(pill?.textContent).not.toContain(m.timeline_layer_historical_suffix());
expect(document.body.textContent).toContain(m.timeline_layer_historical_suffix());
});
});

View File

@@ -0,0 +1,169 @@
<script lang="ts">
import EventPill from './EventPill.svelte';
import WorldBand from './WorldBand.svelte';
import LetterCard from './LetterCard.svelte';
import YearLetterStrip from './YearLetterStrip.svelte';
import { isDense } from './timelineDensity';
import { entryKey } from './entryKey';
import type { components } from '$lib/generated/api';
type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/**
* One year of the timeline: a <section> with a sticky <h2> (REQ-006). Events
* render in DTO order as pills/bands; letters render as individual cards while
* the band holds ≤ 12 (REQ-011) or collapse to a single density strip above that
* (REQ-012). Entries are never re-sorted — DTO order is preserved (REQ-003).
*/
let { year }: { year: TimelineYearDTO } = $props();
type Row =
| { t: 'event'; entry: TimelineEntryDTO }
| { t: 'letter'; entry: TimelineEntryDTO; side: 'left' | 'right' }
| { t: 'strip' };
const letters = $derived(year.entries.filter((e) => e.kind === 'LETTER'));
const dense = $derived(isDense(letters.length));
const rows = $derived.by<Row[]>(() => {
const out: Row[] = [];
let stripInserted = false;
let letterIndex = 0;
for (const entry of year.entries) {
if (entry.kind === 'EVENT') {
out.push({ t: 'event', entry });
} else if (!dense) {
out.push({ t: 'letter', entry, side: letterIndex % 2 === 0 ? 'left' : 'right' });
letterIndex += 1;
} else if (!stripInserted) {
out.push({ t: 'strip' });
stripInserted = true;
}
}
return out;
});
</script>
<section class="py-2">
<h2 class="year-heading">
<span data-testid="year-node" class="year-node bg-brand-navy" aria-hidden="true"></span>
<span
data-testid="year-label"
class="year-label rounded-full bg-brand-navy px-4 py-1 font-serif text-sm font-bold text-white"
>{year.year}</span
>
</h2>
<div class="mt-3 space-y-3">
{#each rows as row (row.t === 'strip' ? `strip-${year.year}` : entryKey(row.entry))}
{#if row.t === 'event'}
{#if row.entry.type === 'HISTORICAL'}
<WorldBand entry={row.entry} />
{:else}
<EventPill entry={row.entry} />
{/if}
{:else if row.t === 'letter'}
<div class="letter-row" data-side={row.side}>
<span data-testid="letter-dot" class="letter-dot bg-surface" aria-hidden="true"></span>
<LetterCard entry={row.entry} />
</div>
{:else}
<YearLetterStrip letters={letters} year={year.year} />
{/if}
{/each}
</div>
</section>
<style>
/* Sticky offset in scoped CSS so it holds in unit tests too (the global
header is a 64px sticky nav). #779 REQ-006 / #833 REQ-003. The sticky
element is also the positioning context for the year-node marker. */
.year-heading {
position: sticky;
top: 4rem;
z-index: 1;
}
/* Markers ride on the spine's single source of truth: --spine-x, declared
once on TimelineView's .timeline-axis and inherited here (0.5rem phone /
50% desktop). The 0.5rem fallback only applies when a YearBand is rendered
outside the axis (e.g. component tests). #833 REQ-003/004/005. */
/* Phone (< 1024px): badge sits at the left spine, clearing the node marker.
The badge sits above the node (z-index) so on desktop, where the centered
pill covers the centered node, the white year text is never occluded. */
.year-label {
display: inline-block;
margin-left: 1.75rem;
position: relative;
z-index: 2;
}
/* Navy node marker on the spine. On phone it shows to the left of the badge;
on desktop it sits behind the centered pill, which is itself the
axis interruption. */
.year-node {
position: absolute;
top: 50%;
left: var(--spine-x, 0.5rem);
width: 11px;
height: 11px;
border-radius: 9999px;
transform: translate(-50%, -50%);
z-index: 1;
}
/* Per-letter connector dot (white fill via bg-surface, mint ring) on the spine. */
.letter-row {
position: relative;
padding-left: 1.75rem;
}
.letter-dot {
position: absolute;
top: 0.9rem;
left: var(--spine-x, 0.5rem);
width: 13px;
height: 13px;
border-radius: 9999px;
border: 2.5px solid var(--palette-mint);
transform: translate(-50%, -50%);
z-index: 2;
}
/* Desktop (≥ 1024px): centered axis. The badge centers on the spine, the node
sits at the spine centre, and letters alternate left/right with the
connector dot on the centred spine between card and axis. #833 REQ-003/004/005. */
@media (min-width: 1024px) {
.year-label {
display: block;
width: fit-content;
margin-left: auto;
margin-right: auto;
}
/* .year-node needs no desktop override — it inherits --spine-x: 50% from
the axis. */
.letter-row {
width: 50%;
padding-left: 0;
}
.letter-row[data-side='left'] {
margin-right: auto;
padding-right: 1.75rem;
}
.letter-row[data-side='right'] {
margin-left: auto;
padding-left: 1.75rem;
}
.letter-row[data-side='left'] .letter-dot {
left: auto;
right: 0;
transform: translate(50%, -50%);
}
.letter-row[data-side='right'] .letter-dot {
left: 0;
right: auto;
transform: translate(-50%, -50%);
}
}
</style>

View File

@@ -0,0 +1,167 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import YearBand from './YearBand.svelte';
import { makeEntry, makeYear } from './test-factories';
afterEach(async () => {
cleanup();
await page.viewport(1280, 720);
});
function manyLetters(year: number, count: number) {
return Array.from({ length: count }, (_, i) =>
makeEntry({ eventDate: `${year}-01-10`, documentId: `doc-${i}` })
);
}
describe('YearBand', () => {
it('renders a section with a sticky h2 at top:4rem showing the year (REQ-006)', () => {
render(YearBand, { year: makeYear(1914, [makeEntry()]) });
const section = document.querySelector('section');
expect(section).not.toBeNull();
const h2 = section?.querySelector('h2');
expect(h2?.textContent).toContain('1914');
const cs = getComputedStyle(h2 as HTMLElement);
expect(cs.position).toBe('sticky');
expect(cs.top).toBe('64px');
});
it('renders each letter as a card when the band holds <= 12 letters (REQ-011)', () => {
render(YearBand, { year: makeYear(1909, manyLetters(1909, 3)) });
expect(document.querySelectorAll('a')).toHaveLength(3);
expect(document.querySelector('[data-testid="strip-expand"]')).toBeNull();
});
it('renders a single strip when the band holds > 12 letters (REQ-012)', () => {
render(YearBand, { year: makeYear(1915, manyLetters(1915, 30)) });
expect(document.querySelector('[data-testid="strip-expand"]')).not.toBeNull();
// collapsed: no individual letter links yet
expect(document.querySelectorAll('a')).toHaveLength(0);
});
it('renders entries in DTO order — DAY-precision letter above a YEAR-precision letter (REQ-003)', () => {
const dayLetter = makeEntry({
precision: 'DAY',
eventDate: '1923-04-12',
title: 'Tagesgenau',
documentId: 'day'
});
const yearLetter = makeEntry({
precision: 'YEAR',
eventDate: '1923-01-01',
title: 'Nur Jahr',
documentId: 'year'
});
render(YearBand, { year: makeYear(1923, [dayLetter, yearLetter]) });
const links = Array.from(document.querySelectorAll('a'));
expect(links[0].getAttribute('href')).toBe('/documents/day');
expect(links[1].getAttribute('href')).toBe('/documents/year');
});
it('renders an EVENT as a pill and a HISTORICAL event as a band', () => {
const pill = makeEntry({
kind: 'EVENT',
derived: true,
derivedType: 'MARRIAGE',
title: 'Heirat',
senderName: '',
receiverName: '',
documentId: undefined
});
const band = makeEntry({
kind: 'EVENT',
derived: false,
type: 'HISTORICAL',
precision: 'RANGE',
eventDate: '1914-01-01',
eventDateEnd: '1918-12-31',
title: 'Erster Weltkrieg',
senderName: '',
receiverName: '',
documentId: undefined
});
render(YearBand, { year: makeYear(1914, [pill, band]) });
expect(document.body.textContent).toContain('Heirat');
expect(document.querySelector('[data-testid="world-range"]')).not.toBeNull();
});
it('centers the year badge on the axis at desktop (REQ-003)', async () => {
await page.viewport(1440, 900);
render(YearBand, { year: makeYear(1914, [makeEntry()]) });
const section = document.querySelector('section') as HTMLElement;
const badge = document.querySelector('[data-testid="year-label"]') as HTMLElement;
const s = section.getBoundingClientRect();
const b = badge.getBoundingClientRect();
const sectionCenter = s.left + s.width / 2;
const badgeCenter = b.left + b.width / 2;
expect(Math.abs(badgeCenter - sectionCenter)).toBeLessThan(8);
});
it('left-aligns the year badge at phone width (REQ-003)', async () => {
await page.viewport(375, 800);
render(YearBand, { year: makeYear(1914, [makeEntry()]) });
const section = document.querySelector('section') as HTMLElement;
const badge = document.querySelector('[data-testid="year-label"]') as HTMLElement;
const s = section.getBoundingClientRect();
const b = badge.getBoundingClientRect();
// hugs the left spine — clearly not centered
expect(b.left - s.left).toBeLessThan(s.width / 3);
});
it('keeps the sticky year heading at top:4rem (REQ-003)', () => {
render(YearBand, { year: makeYear(1914, [makeEntry()]) });
const h2 = document.querySelector('h2') as HTMLElement;
const cs = getComputedStyle(h2);
expect(cs.position).toBe('sticky');
expect(cs.top).toBe('64px');
});
it('renders a year-badge node marker that clears the badge text on phone (REQ-004)', async () => {
await page.viewport(375, 800);
render(YearBand, { year: makeYear(1914, [makeEntry()]) });
const node = document.querySelector('[data-testid="year-node"]') as HTMLElement;
const label = document.querySelector('[data-testid="year-label"]') as HTMLElement;
expect(node).not.toBeNull();
const n = node.getBoundingClientRect();
const l = label.getBoundingClientRect();
expect(n.right).toBeLessThanOrEqual(l.left + 0.5);
// The badge must paint above the node so the centered desktop pill never
// occludes the white year text (regression guard).
expect(Number(getComputedStyle(label).zIndex)).toBeGreaterThan(
Number(getComputedStyle(node).zIndex)
);
});
it('renders one connector dot per letter row, each clearing its card on phone (REQ-005)', async () => {
await page.viewport(375, 800);
render(YearBand, { year: makeYear(1909, manyLetters(1909, 3)) });
const dots = document.querySelectorAll('[data-testid="letter-dot"]');
expect(dots).toHaveLength(3);
const row = document.querySelector('.letter-row') as HTMLElement;
const dot = row.querySelector('[data-testid="letter-dot"]') as HTMLElement;
const card = row.querySelector('a') as HTMLElement;
expect(dot.getBoundingClientRect().right).toBeLessThanOrEqual(
card.getBoundingClientRect().left + 0.5
);
});
it('positions the year-node from the inherited --spine-x token (REQ-003/004)', async () => {
await page.viewport(375, 800);
// The spine X lives once on .timeline-axis as --spine-x; the markers must
// track that token, not a hard-coded offset, so they never desync.
document.documentElement.style.setProperty('--spine-x', '3rem');
try {
render(YearBand, { year: makeYear(1914, [makeEntry()]) });
const node = document.querySelector('[data-testid="year-node"]') as HTMLElement;
const section = document.querySelector('section') as HTMLElement;
const n = node.getBoundingClientRect();
const s = section.getBoundingClientRect();
const nodeCenter = n.left + n.width / 2;
// --spine-x:3rem = 48px from the band's left edge
expect(Math.abs(nodeCenter - s.left - 48)).toBeLessThan(2);
} finally {
document.documentElement.style.removeProperty('--spine-x');
}
});
});

View File

@@ -0,0 +1,71 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { getLocale } from '$lib/paraglide/runtime.js';
import { formatTickLabel } from '$lib/shared/utils/monthBuckets';
import Sparkline from '$lib/shared/primitives/Sparkline.svelte';
import GlyphLabel from './GlyphLabel.svelte';
import LetterCard from './LetterCard.svelte';
import { monthHistogram } from './timelineDensity';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/**
* Compact density view for a year with many letters (REQ-012): the letter count
* plus a 12-month density sparkline, and a ≥44px keyboard-focusable toggle that
* expands to that year's individual LetterCards. The count carries a ✉ glyph and
* the sparkline is captioned with its month-density meaning and Jan/Dez endpoint
* labels (REQ-010/011).
*/
let { letters, year }: { letters: TimelineEntryDTO[]; year: number } = $props();
let expanded = $state(false);
const counts = $derived(monthHistogram(letters, year).map((b) => b.count));
// Two endpoint month labels only (not one per bar). Pass the Jan/Dez anchors so
// the locale formatter returns a month (a bare "{year}" returns just the year).
const janLabel = $derived(formatTickLabel(`${year}-01`, getLocale()));
const dezLabel = $derived(formatTickLabel(`${year}-12`, getLocale()));
</script>
<div class="mx-auto max-w-md rounded-sm border border-line bg-surface p-3 shadow-sm">
<div class="flex items-center justify-between gap-3">
<span class="font-sans text-sm font-bold text-brand-navy">
<GlyphLabel glyph="✉" label={m.timeline_letter_glyph_label()} />
{m.timeline_letters_count({ count: letters.length })}
</span>
<button
type="button"
data-testid="strip-expand"
aria-expanded={expanded}
onclick={() => (expanded = !expanded)}
style="display: inline-flex; align-items: center; min-height: 44px"
class="rounded-sm px-2 font-sans text-xs text-ink-3 hover:text-brand-navy focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
>
{m.timeline_strip_expand()}
</button>
</div>
<Sparkline
values={counts}
label={m.timeline_letters_count({ count: letters.length })}
class="mt-2"
/>
<!-- Two endpoint month labels + the density caption, beneath the sparkline
(REQ-010/011). 10px is the floor for this micro-axis (the spec's 6px is
below this project's legibility floor for the 60+ transcriber audience). -->
<div class="mt-1 flex items-center justify-between gap-2 font-sans text-[10px] text-ink-3">
<span data-testid="strip-axis-label">{janLabel}</span>
<span>{m.timeline_strip_density_caption()}</span>
<span data-testid="strip-axis-label">{dezLabel}</span>
</div>
{#if expanded}
<ul class="mt-3 space-y-2">
{#each letters as letter (letter.documentId)}
<li><LetterCard entry={letter} /></li>
{/each}
</ul>
{/if}
</div>

View File

@@ -0,0 +1,73 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { tick } from 'svelte';
import * as m from '$lib/paraglide/messages.js';
import { getLocale } from '$lib/paraglide/runtime.js';
import { formatTickLabel } from '$lib/shared/utils/monthBuckets';
import YearLetterStrip from './YearLetterStrip.svelte';
import { makeEntry } from './test-factories';
afterEach(() => cleanup());
function denseLetters(year: number, count: number) {
return Array.from({ length: count }, (_, i) =>
makeEntry({
eventDate: `${year}-${String((i % 12) + 1).padStart(2, '0')}-10`,
documentId: `doc-${i}`
})
);
}
describe('YearLetterStrip', () => {
it('shows the letter count and a 12-bar sparkline (REQ-012)', () => {
render(YearLetterStrip, { letters: denseLetters(1915, 30), year: 1915 });
expect(document.body.textContent).toContain('30');
const bars = document.querySelectorAll('[data-testid="sparkline-bar"]');
expect(bars).toHaveLength(12);
});
it('has a keyboard-focusable expand toggle of at least 44px (REQ-012)', () => {
render(YearLetterStrip, { letters: denseLetters(1915, 30), year: 1915 });
const toggle = document.querySelector('[data-testid="strip-expand"]') as HTMLButtonElement;
expect(toggle).not.toBeNull();
expect(toggle.tagName).toBe('BUTTON');
expect(toggle.getBoundingClientRect().height).toBeGreaterThanOrEqual(44);
});
it('reveals all letter cards when expanded (REQ-012)', async () => {
render(YearLetterStrip, { letters: denseLetters(1915, 30), year: 1915 });
expect(document.querySelectorAll('a').length).toBe(0);
const toggle = document.querySelector('[data-testid="strip-expand"]') as HTMLButtonElement;
toggle.click();
await tick();
expect(document.querySelectorAll('a').length).toBe(30);
});
it('prefixes the count with an aria-hidden ✉ + sr-only "Brief" and shows the density caption (REQ-010)', () => {
render(YearLetterStrip, { letters: denseLetters(1915, 30), year: 1915 });
const hidden = document.querySelector('[aria-hidden="true"]');
expect(hidden?.textContent).toContain('✉');
const srOnly = document.querySelector('.sr-only');
expect(srOnly?.textContent).toBe(m.timeline_letter_glyph_label());
expect(document.body.textContent).toContain(m.timeline_strip_density_caption());
});
it('keeps the expand toggle and its "Briefe anzeigen" label alongside the new chrome (REQ-010)', () => {
render(YearLetterStrip, { letters: denseLetters(1915, 30), year: 1915 });
const toggle = document.querySelector('[data-testid="strip-expand"]') as HTMLButtonElement;
expect(toggle).not.toBeNull();
expect(toggle.textContent).toContain(m.timeline_strip_expand());
expect(toggle.getBoundingClientRect().height).toBeGreaterThanOrEqual(44);
});
it('renders exactly two endpoint month-axis labels (Jan/Dez {year}) at ≥10px (REQ-011)', () => {
render(YearLetterStrip, { letters: denseLetters(1915, 30), year: 1915 });
const labels = document.querySelectorAll('[data-testid="strip-axis-label"]');
expect(labels).toHaveLength(2);
expect(labels[0].textContent).toContain(formatTickLabel('1915-01', getLocale()));
expect(labels[1].textContent).toContain(formatTickLabel('1915-12', getLocale()));
for (const label of labels) {
expect(parseFloat(getComputedStyle(label).fontSize)).toBeGreaterThanOrEqual(10);
}
});
});

View File

@@ -0,0 +1,23 @@
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/**
* Stable `{#each}` key for a timeline entry. Prefers the entry's own identity
* (`eventId` for curated events, `documentId` for letters); derived life-events
* carry neither, so they key on `derivedType` + their linked person ids — which
* keeps two derived births in the same year distinct. The `kind` prefix keeps an
* event and a letter that happen to share an id from colliding.
*
* Used by both `YearBand` (per-band rows) and `TimelineView` (the undated
* bucket), where entries can be events without a `documentId`.
*/
export function entryKey(entry: TimelineEntryDTO): string {
return (
entry.kind +
':' +
(entry.eventId ??
entry.documentId ??
`${entry.derivedType}:${(entry.linkedPersonIds ?? []).join('-')}`)
);
}

View File

@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { getAccentConfig } from './eventCardConfig';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
function event(overrides: Partial<TimelineEntryDTO>): TimelineEntryDTO {
return {
kind: 'EVENT',
precision: 'YEAR',
derived: false,
senderName: '',
receiverName: '',
...overrides
};
}
describe('getAccentConfig', () => {
it('maps a derived birth to the * glyph and "Geburt"', () => {
const cfg = getAccentConfig(event({ derived: true, derivedType: 'BIRTH' }));
expect(cfg.glyph).toBe('*');
expect(cfg.label).toBe('Geburt');
expect(cfg.accent).toBe('derived');
});
it('maps a derived death to the † glyph and "Tod"', () => {
const cfg = getAccentConfig(event({ derived: true, derivedType: 'DEATH' }));
expect(cfg.glyph).toBe('†');
expect(cfg.label).toBe('Tod');
expect(cfg.accent).toBe('derived');
});
it('maps a derived marriage to the ⚭ glyph and "Heirat"', () => {
const cfg = getAccentConfig(event({ derived: true, derivedType: 'MARRIAGE' }));
expect(cfg.glyph).toBe('⚭');
expect(cfg.label).toBe('Heirat');
expect(cfg.accent).toBe('derived');
});
it('maps a HISTORICAL event to the world glyph and "Weltgeschehen"', () => {
const cfg = getAccentConfig(event({ type: 'HISTORICAL' }));
expect(cfg.glyph).toBe('◍');
expect(cfg.label).toBe('Weltgeschehen');
expect(cfg.accent).toBe('historical');
});
it('maps a curated PERSONAL event to the ★ glyph and "Familie"', () => {
const cfg = getAccentConfig(event({ type: 'PERSONAL', eventId: 'e-1' }));
expect(cfg.glyph).toBe('★');
expect(cfg.label).toBe('Familie');
expect(cfg.accent).toBe('curated');
});
});

View File

@@ -0,0 +1,38 @@
import * as m from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/** Styling discriminant for an axis pill/band. */
export type TimelineAccent = 'derived' | 'curated' | 'historical';
export interface AccentConfig {
/** Visible Unicode glyph — render `aria-hidden`, paired with an sr-only label. */
glyph: string;
/** Localized layer/life-event label — used as the sr-only / aria text only. */
label: string;
accent: TimelineAccent;
}
/**
* Maps a timeline EVENT entry to its glyph, redundant non-color label, and accent
* (REQ-007/008/018). Derived life-events use the * / † / ⚭ glyphs that match
* `personLifeDates.ts`; HISTORICAL events get the muted world band; everything
* else (curated PERSONAL) gets the mint family pill.
*/
export function getAccentConfig(entry: TimelineEntryDTO): AccentConfig {
if (entry.derived) {
switch (entry.derivedType) {
case 'BIRTH':
return { glyph: '*', label: m.timeline_derived_birth(), accent: 'derived' };
case 'DEATH':
return { glyph: '†', label: m.timeline_derived_death(), accent: 'derived' };
case 'MARRIAGE':
return { glyph: '⚭', label: m.timeline_derived_marriage(), accent: 'derived' };
}
}
if (entry.type === 'HISTORICAL') {
return { glyph: '◍', label: m.timeline_layer_world(), accent: 'historical' };
}
return { glyph: '★', label: m.timeline_layer_family(), accent: 'curated' };
}

View File

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

View File

@@ -0,0 +1,34 @@
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
type TimelineDTO = components['schemas']['TimelineDTO'];
/**
* Builds a `TimelineEntryDTO` mirroring the real wire shape (no `year`,
* `description`, or `snippet` fields). Defaults to a dated DAY-precision letter;
* override `kind`/`derived`/`type`/`derivedType` etc. for events.
*/
export function makeEntry(overrides: Partial<TimelineEntryDTO> = {}): TimelineEntryDTO {
return {
kind: 'LETTER',
precision: 'DAY',
derived: false,
senderName: 'Karl Raddatz',
receiverName: 'Elfriede Raddatz',
eventDate: '1915-06-15',
title: 'Brief aus dem Feld',
documentId: '11111111-1111-1111-1111-111111111111',
...overrides
};
}
export function makeYear(year: number, entries: TimelineEntryDTO[]): TimelineYearDTO {
return { year, entries };
}
export function makeTimelineDTO(
opts: { years?: TimelineYearDTO[]; undated?: TimelineEntryDTO[] } = {}
): TimelineDTO {
return { years: opts.years ?? [], undated: opts.undated ?? [] };
}

View File

@@ -0,0 +1,110 @@
import { describe, it, expect } from 'vitest';
import { isDense, monthHistogram, DENSE_THRESHOLD } from './timelineDensity';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
function letter(eventDate: string): TimelineEntryDTO {
return {
kind: 'LETTER',
precision: 'DAY',
derived: false,
senderName: 'Karl',
receiverName: 'Elfriede',
eventDate
};
}
describe('isDense', () => {
it('uses a threshold of 12', () => {
expect(DENSE_THRESHOLD).toBe(12);
});
it('is false at exactly 12 letters (still rendered as individual cards)', () => {
expect(isDense(12)).toBe(false);
});
it('is true above 12 letters (collapses to a strip)', () => {
expect(isDense(13)).toBe(true);
});
it('is false for empty and small bands', () => {
expect(isDense(0)).toBe(false);
expect(isDense(3)).toBe(false);
});
});
describe('monthHistogram', () => {
it('returns exactly 12 buckets for the band year, Jan..Dec', () => {
const buckets = monthHistogram([letter('1915-03-04')], 1915);
expect(buckets).toHaveLength(12);
expect(buckets.map((b) => b.month)).toEqual([
'1915-01',
'1915-02',
'1915-03',
'1915-04',
'1915-05',
'1915-06',
'1915-07',
'1915-08',
'1915-09',
'1915-10',
'1915-11',
'1915-12'
]);
});
it('counts each letter on its eventDate month; counts sum to the total', () => {
// 30 letters spread one-or-more per month across 1915.
const dist: Record<string, number> = {
'01': 1,
'02': 2,
'03': 3,
'04': 4,
'05': 1,
'06': 5,
'07': 2,
'08': 6,
'09': 1,
'10': 2,
'11': 2,
'12': 1
};
const letters: TimelineEntryDTO[] = [];
for (const [mm, n] of Object.entries(dist)) {
for (let i = 0; i < n; i++) letters.push(letter(`1915-${mm}-10`));
}
expect(letters).toHaveLength(30);
const buckets = monthHistogram(letters, 1915);
expect(buckets.reduce((sum, b) => sum + b.count, 0)).toBe(30);
for (const b of buckets) {
expect(b.count).toBe(dist[b.month.slice(5)]);
}
});
it('yields height 0 for the eleven empty months when letters cluster in one', () => {
const buckets = monthHistogram([letter('1915-03-01'), letter('1915-03-28')], 1915);
const march = buckets.find((b) => b.month === '1915-03');
expect(march?.count).toBe(2);
expect(buckets.filter((b) => b.month !== '1915-03').every((b) => b.count === 0)).toBe(true);
});
it('counts coarser-than-month precisions on their eventDate anchor month', () => {
const seasonLetter: TimelineEntryDTO = { ...letter('1915-07-01'), precision: 'SEASON' };
const buckets = monthHistogram([seasonLetter], 1915);
expect(buckets.find((b) => b.month === '1915-07')?.count).toBe(1);
});
it('ignores entries without an eventDate', () => {
const undated: TimelineEntryDTO = {
kind: 'LETTER',
precision: 'UNKNOWN',
derived: false,
senderName: 'Karl',
receiverName: 'Elfriede'
};
const buckets = monthHistogram([undated, letter('1915-05-01')], 1915);
expect(buckets.reduce((sum, b) => sum + b.count, 0)).toBe(1);
});
});

View File

@@ -0,0 +1,32 @@
import type { components } from '$lib/generated/api';
import { fillDensityGaps, type MonthBucket } from '$lib/shared/utils/monthBuckets';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/**
* A year band with more letters than this renders as a compact density strip
* (count + 12-month sparkline) instead of one card per letter (REQ-012).
*/
export const DENSE_THRESHOLD = 12;
export function isDense(letterCount: number): boolean {
return letterCount > DENSE_THRESHOLD;
}
/**
* Buckets a band's letters into exactly 12 month buckets (`{year}-01`..`{year}-12`)
* for the density sparkline. Each letter counts on its `eventDate` month; coarser
* precisions (SEASON/YEAR/APPROX) count on whatever anchor month the backend put
* in `eventDate`. Entries without an `eventDate` (e.g. UNKNOWN) are ignored — they
* live in the "Ohne Datum" bucket, not a dated band. (REQ-027)
*/
export function monthHistogram(letters: TimelineEntryDTO[], year: number): MonthBucket[] {
const counts = new Map<string, number>();
for (const l of letters) {
if (!l.eventDate) continue;
const month = l.eventDate.slice(0, 7); // YYYY-MM
counts.set(month, (counts.get(month) ?? 0) + 1);
}
const buckets = Array.from(counts.entries()).map(([month, count]) => ({ month, count }));
return fillDensityGaps(buckets, `${year}-01-01`, `${year}-12-31`);
}

View File

@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { timelineMeta } from './timelineMeta';
import { makeEntry, makeYear, makeTimelineDTO } from './test-factories';
const letter = (id: string) => makeEntry({ kind: 'LETTER', documentId: id });
const event = (title: string) =>
makeEntry({
kind: 'EVENT',
derived: true,
derivedType: 'BIRTH',
title,
senderName: '',
receiverName: '',
documentId: undefined
});
describe('timelineMeta', () => {
it('counts letters and events across year bands and the undated bucket (REQ-002)', () => {
const dto = makeTimelineDTO({
years: [
makeYear(1909, [letter('a'), event('Geburt'), letter('b')]),
makeYear(1924, [event('Tod')])
],
undated: [letter('c'), event('Heirat')]
});
const meta = timelineMeta(dto);
expect(meta.letterCount).toBe(3);
expect(meta.eventCount).toBe(3);
});
it('reads the range from the first and last year band (REQ-002)', () => {
const dto = makeTimelineDTO({
years: [makeYear(1909, [letter('a')]), makeYear(1924, [letter('b')])]
});
const meta = timelineMeta(dto);
expect(meta.firstYear).toBe(1909);
expect(meta.lastYear).toBe(1924);
});
it('has a null range when there are no year bands, but still counts undated (REQ-002)', () => {
const dto = makeTimelineDTO({ undated: [letter('a')] });
const meta = timelineMeta(dto);
expect(meta.firstYear).toBeNull();
expect(meta.lastYear).toBeNull();
expect(meta.letterCount).toBe(1);
});
it('reports zero counts and a null range for an empty timeline (REQ-002)', () => {
expect(timelineMeta(makeTimelineDTO())).toEqual({
firstYear: null,
lastYear: null,
letterCount: 0,
eventCount: 0
});
});
});

View File

@@ -0,0 +1,38 @@
import type { components } from '$lib/generated/api';
type TimelineDTO = components['schemas']['TimelineDTO'];
export interface TimelineMeta {
/** First year band's year, or `null` when there are no bands. */
firstYear: number | null;
/** Last year band's year, or `null` when there are no bands. */
lastYear: number | null;
/** Every `LETTER` entry across all year bands plus the undated bucket. */
letterCount: number;
/** Every `EVENT` entry (derived, curated, and historical) across all bands plus undated. */
eventCount: number;
}
/**
* Derives the header meta-line figures from a loaded `TimelineDTO` (REQ-002):
* the year range (first/last band) and the letter/event totals across every
* year band plus the undated bucket. Pure and the single place these counts
* live — the route renders them; `TimelineView` never recomputes them.
*/
export function timelineMeta(timeline: TimelineDTO): TimelineMeta {
const years = timeline.years;
let letterCount = 0;
let eventCount = 0;
const tally = (e: TimelineDTO['undated'][number]) => {
if (e.kind === 'LETTER') letterCount += 1;
else if (e.kind === 'EVENT') eventCount += 1;
};
for (const y of years) for (const e of y.entries) tally(e);
for (const e of timeline.undated) tally(e);
return {
firstYear: years.length ? years[0].year : null,
lastYear: years.length ? years[years.length - 1].year : null,
letterCount,
eventCount
};
}

View File

@@ -78,6 +78,16 @@ function handleOverlayKeydown(event: KeyboardEvent) {
>
{m.nav_geschichten()}
</a>
<a
href="/zeitstrahl"
class="my-2 inline-flex items-center px-3 font-sans text-xs font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-focus-ring
{page.url.pathname.startsWith('/zeitstrahl')
? 'border-b-2 border-accent text-white'
: 'text-white/70 hover:text-white'}"
>
{m.nav_zeitstrahl()}
</a>
{#if isAdmin}
<a
href="/admin"
@@ -190,6 +200,16 @@ function handleOverlayKeydown(event: KeyboardEvent) {
{m.nav_geschichten()}
</a>
<a
href="/zeitstrahl"
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
{page.url.pathname.startsWith('/zeitstrahl')
? 'bg-accent-bg text-ink'
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
>
{m.nav_zeitstrahl()}
</a>
{#if isAdmin}
<a
href="/admin"

View File

@@ -0,0 +1,19 @@
import { error, redirect } from '@sveltejs/kit';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
// Global timeline: personId is undefined, so no query params (REQ-001). SSR-first
// via createApiClient so the session cookie is forwarded; no client-side fetch
// (REQ-002). The raw payload (correspondent names/titles) is PII — never logged.
export async function load({ fetch }) {
const api = createApiClient(fetch);
const result = await api.GET('/api/timeline');
if (result.response.status === 401) throw redirect(302, '/login');
if (!result.response.ok) {
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error)));
}
return { timeline: result.data! };
}

View File

@@ -0,0 +1,57 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import TimelineView from '$lib/timeline/TimelineView.svelte';
import { timelineMeta } from '$lib/timeline/timelineMeta';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const meta = $derived(timelineMeta(data.timeline));
const hasContent = $derived(data.timeline.years.length > 0 || data.timeline.undated.length > 0);
// Compose the sub-line from segments joined by " · " so the range drops out
// cleanly when there are no year bands; the whole line is absent when the
// timeline is empty (REQ-002). Counts come from the route alone, never from
// TimelineView.
const metaLine = $derived.by(() => {
const segments: string[] = [];
if (meta.firstYear !== null && meta.lastYear !== null) {
segments.push(`${meta.firstYear}${meta.lastYear}`);
}
// A zero-count segment ("0 Briefe") reads as a data error — drop it; a count
// of one takes the singular key ("1 Brief"), per the project plural convention.
if (meta.letterCount > 0) {
segments.push(
meta.letterCount === 1
? m.timeline_letters_count_singular()
: m.timeline_letters_count({ count: meta.letterCount })
);
}
if (meta.eventCount > 0) {
segments.push(
meta.eventCount === 1
? m.timeline_events_count_singular()
: m.timeline_events_count({ count: meta.eventCount })
);
}
segments.push(m.timeline_grouping_date());
return segments.join(' · ');
});
</script>
<svelte:head>
<title>{m.timeline_heading()}</title>
</svelte:head>
<div class="mx-auto max-w-5xl px-4 py-8">
<!-- The .tl-canvas sheet: a padded canvas surface for the timeline. The outer
border is intentionally omitted (the page is already bg-canvas), per the
review of REQ-001 — the sheet reads through its padding, not a frame line. -->
<div data-testid="timeline-canvas" class="rounded-[10px] bg-canvas p-6">
<h1 class="font-serif text-2xl font-bold text-brand-navy">{m.timeline_heading()}</h1>
{#if hasContent}
<p data-testid="timeline-meta" class="mt-1 mb-6 font-sans text-xs text-ink-3">{metaLine}</p>
{/if}
<TimelineView timeline={data.timeline} />
</div>
</div>

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));
}
};

Some files were not shown because too many files have changed in this diff Show More