Compare commits

..

103 Commits

Author SHA1 Message Date
Marcel
4bcf568ed4 Merge branch 'main' of ssh://git.raddatz.cloud:222/marcel/familienarchiv
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m22s
CI / OCR Service Tests (push) Successful in 23s
CI / Backend Unit Tests (push) Successful in 3m41s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m7s
nightly / deploy-staging (push) Successful in 4m38s
2026-06-08 16:27:41 +02:00
Marcel
ddb1ec4df8 docs(timeline): add Zeitstrahl visual specs (global Concept A, event editor)
Visual design specs for Milestone #14:
- zeitstrahl-global-concepts.html — A/B/C exploration of the global timeline
- zeitstrahl-final-spec.html — canonical Concept A (global + per-person Lebensweg)
- zeitstrahl-event-editor-spec.html — curator event editor + document quick-action

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 16:27:15 +02:00
d650b6c066 refactor(search): remove NLP/smart-search feature entirely (#772)
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m23s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 3m46s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 25s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
## Summary

- Removes the NLP/smart-search feature completely — the feature was too unreliable and slow; users get better results with the regular search filters
- Deletes the entire backend `search/` package (NlSearchController, NlQueryParserService, NlpClient, NlSearchRateLimiter — 14 classes + 6 test classes)
- Deletes the `nlp-service/` Python microservice (FastAPI, rapidfuzz, DB-backed person matching)
- Removes all frontend NL search components: SmartModeToggle, SmartSearchStatus, InterpretationChipRow, DisambiguationPicker, chip-types, theme-chip-removal
- Strips smart-mode logic from SearchFilterBar and documents/+page.svelte
- Removes `SMART_SEARCH_UNAVAILABLE` / `SMART_SEARCH_RATE_LIMITED` error codes from backend, frontend types, and all three i18n files (de/en/es)
- Removes `nlp-service` container and `APP_NLP_BASE_URL` from both docker-compose files
- Removes Ollama/NLP Prometheus scrape job and Grafana dashboard
- Deletes ADRs 028 (×2), 034, 035

## Test plan

- [ ] Backend compiles: `cd backend && ./mvnw compile -q` → BUILD SUCCESS
- [ ] Frontend server tests pass: `cd frontend && npm run test -- --project=server`
- [ ] No NLP/smart-search references remain in source: `grep -r "SmartSearch\|NlSearch\|nlp-service\|SMART_SEARCH" backend/src frontend/src`
- [ ] `docker compose config` validates both compose files
- [ ] Search page loads, filter bar works, no smart-mode toggle visible

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Marcel <marcel@familienarchiv>
Reviewed-on: #772
2026-06-08 10:57:00 +02:00
Marcel
e63eaadc33 docs(timeline): add Person date+precision migration as foundational issue
Replace Person birthYear/deathYear integers with birthDate/deathDate +
DatePrecision so known exact birthdays render precisely. Migration,
re-import preservation rule, and bounded blast radius captured; becomes
issue 1 the timeline's derived events depend on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 19:26:13 +02:00
Marcel
d4a25e34d8 docs(timeline): add family timeline (Zeitstrahl) design spec
Hand-curated, year-banded vertical timeline weaving derived person
life-events, curated personal/historical events, and date-placed
letters. Includes proposed sub-issue breakdown for a milestone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 19:18:55 +02:00
Marcel
8e63867ad8 docs(specs): UI specs for Lesereisen reader and Journey editor
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m23s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 4m2s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 21s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
nightly / deploy-staging (push) Successful in 2m44s
lesereisen-reader-spec.html — Issue #752
  LR-0 type selector on /geschichten/new
  LR-1 REISE badge on the list
  LR-2 Journey reader (ordered cards, interlude asides, no position numbers)

lesereisen-editor-spec.html — Issue #753
  LE-1 empty JourneyEditor layout
  LE-2 editor with mixed items (documents + interludes, drag handles)
  LE-3 inline note-editing state
  LE-4 mobile layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 19:07:34 +02:00
Marcel
6b0a06e8b1 feat(nlp-service): scaffold — models, requirements, CLAUDE.md
Task 1: Create standalone FastAPI service scaffold with models, test framework,
and documentation. Includes ParseRequest, ParseResponse Pydantic models matching
OllamaExtraction contract, plus three passing tests validating model validation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 10:11:34 +02:00
Marcel
7c1eef710c docs(nlp): add spaCy NLP service implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 09:52:07 +02:00
Marcel
03e22a2f26 docs(nlp): add spaCy NLP service prototype design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 09:40:00 +02:00
Marcel
6878419156 merge: resolve conflicts with origin/main (#763 person name-match integration)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m31s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 3m48s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
CI / Unit & Component Tests (push) Successful in 3m20s
CI / OCR Service Tests (push) Successful in 23s
CI / Backend Unit Tests (push) Successful in 3m48s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 23s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
- Drop unused MAX_CANDIDATES constant (not referenced in service)
- Keep detached-entity safety comment in resolveTags()
- Add 3 new partial-name match tests (23a/b/c) from #763
- Use resolveByName() API in test 28 (replaces findByDisplayNameContaining)
- Add NameMatches glossary entry from #763

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:50:48 +02:00
Marcel
09b77e9b36 test(person): pin fetchPool dedup when one person matches two tokens (#763 review)
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m20s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 3m53s
CI / fail2ban Regex (push) Successful in 44s
CI / Semgrep Security Scan (push) Successful in 21s
CI / Compose Bucket Idempotency (push) Successful in 1m5s
Assert that when the same person id is returned by two different token
fetches, the person appears exactly once in the result -- pinning
fetchPool's putIfAbsent dedup so a future refactor can't silently
double-classify a candidate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
9d202b042b test(person): close fetch-to-classify seam for alias matches on real Postgres (#763 review)
AC#4 (maiden alias -> direct) and AC#5 (alias first name -> fetchable +
classifiable) were each split across PersonRepositoryTest (the fetch) and
PersonServiceTest (the classifier with stubs) -- nothing walked
searchByName -> resolveByName end-to-end on real Postgres. Add two tests
in the existing @DataJpaTest slice that build a real PersonService over
the autowired repositories, persist a person with a MAIDEN_NAME alias and
one with an alias firstName, and assert both classify as direct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
8429b1e9f8 fix(search): derive disambiguation trigger aria-label from match count (#763 review)
The trigger hardcoded the multiple-people label for every count, so a
single did-you-mean picker announced "Mehrere Personen gefunden" to
screen readers while sighted users saw one name and a "Meintest du …?"
heading. Derive the trigger's accessible name from persons.length: a
single suggestion reuses the heading prop, two or more keep the
multiple-people label. Visible truncated name span unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
6959651b36 docs(search): document NameMatches and resolveByName (#763)
GLOSSARY entry for NameMatches (direct vs partial name-match strength and how
the search layer maps it); person/README adds resolveByName to the public
surface. No ADR — the matching rule is localized and justified inline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
0ef4f4f07c feat(search): case-appropriate disambiguation picker copy (#763)
A 1-item picker now reads "Meintest du …?" (a single direct match auto-selects
and never reaches the picker), while ≥2 keeps the "Person auswählen" framing.
The prompt lives in a visible, non-truncated panel heading (the trigger span
clips at 320px), and the "(auswählen…)" cue is dropped for the 1-item case.
DisambiguationPicker takes heading + showCue props; the page derives both from
ambiguousPersons.length. New search_disambiguation_did_you_mean key in de/en/es.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
f1bb9d3a69 feat(search): map direct/partial NameMatches into resolve buckets (#763)
resolveNames now delegates to PersonService.resolveByName and maps by match
strength: 1 direct → resolved (auto-select), ≥2 direct → ambiguous, 0 direct
with partials → ambiguous suggestions, 0 candidates → folded into full-text.
A single direct match no longer forces the picker when looser substring hits
coexist. The MAX_CANDIDATES cap moved into PersonService (after classification);
the MAX_NAME_LENGTH guard, resolved-cap overflow, and sender/receiver mapping
are preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
ca52145556 feat(person): add resolveByName for direct/partial name matching (#763)
Token-set containment over all of a person's name components (firstName,
lastName, alias, each PersonNameAlias first+last, title) decides direct vs
partial. Orchestrates tokenize → cap(8) → fetch pool → classify → cap(10)
after classification, with an empty-token guard and a PII-free debug log of
the outcome bucket. MAX_TOKENS is a DoS control; the after-classify cap keeps a
direct match that sorts past position 10 among partials. Read-only transaction
keeps lazy nameAliases reachable during classification (ADR-022).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
9a26bf75b0 feat(person): match alias first names in searchByName (#763)
The direct-match classifier accepts alias firstName tokens, so the fetch must
surface candidates matchable only via an alias first name. Add a.firstName to
the searchByName LIKE clause (reuses the bound :query — injection-proof). The
person_name_aliases.first_name column already exists; no migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
9c616f9fb8 feat(person): add name-match tokenizer for direct matching (#763)
Lowercase, split on whitespace/hyphen/apostrophe, drop empties. Applied
symmetrically to query and candidate name components so "Anna-Maria" and
"Anna Maria" tokenize alike. Foundation for resolveByName direct matching.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
0fe0ae5235 docs(search): ADR-028 fix + glossary + C4 diagram for tag resolution (#743)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
2c909f49a8 feat(search): wire theme chip removal to URL navigation in +page.svelte
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
87fd0f39bb feat(search): render removable theme chips in InterpretationChipRow
When tagsApplied is true, each resolvedTag renders as a 'Thema: Name'
chip with optional inline color style from the tag's resolved color.
Clicking × calls onRemoveChip('theme', tag.name).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
7f3ad8ce89 feat(api): add TagHint schema and extend NlQueryInterpretation with resolvedTags/tagsApplied
Manual update since Docker compose backend runs old build; regenerate with
npm run generate:api once new backend is deployed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
aa1f6436cc feat(i18n): add search_chip_theme_prefix to de/en/es message bundles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
b825076733 test(search): DataJpaTest for descendant-expansion via TagRepository
Verifies the recursive CTE in findDescendantIdsByName expands a parent tag
to include all child IDs, and that findByNameContainingIgnoreCase matches
both parent and child names when the fragment appears in both.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
01df815bad test(search): add 11 tag-resolution test cases to NlQueryParserServiceTest
Covers multi-tag match, no-match FTS fallback, mixed resolution, personRole
bypass, cap at 10, short-keyword skip, dedup, rawQuery suppression when all
keywords resolve, flag independence, colour propagation via resolveEffectiveColors,
and colour=null when depth constraint prevents resolution.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
dcd0e725a7 feat(search): implement keyword→tag resolution in NlQueryParserService
Keywords that substring-match the tag taxonomy become OR-union tag filters;
non-matching keywords stay as FTS text. Resolved tags surface in the
NlQueryInterpretation as TagHint objects with effective colours. The
rawQuery fallback is now guarded by hadStructuredMatch to prevent
double-apply when all keywords resolve.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
39ff63921d refactor(search): extract ChipType to chip-types.ts; audit NL fixtures
Pre-implementation step for #743: ChipType union extracted from
InterpretationChipRow and +page.svelte into shared chip-types.ts;
resolvedTags/tagsApplied neutral defaults added to test fixtures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
5a09cd4cb4 feat(search): extend NlQueryInterpretation with resolvedTags + tagsApplied
Positional record fields added; all 3 construction sites updated with neutral
defaults; NlQueryParserService wired for TagService (4th constructor arg);
NlQueryParserServiceTest and NlSearchControllerTest synced.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
4e0ebc72c8 feat(search): add TagHint record for NL tag resolution API surface
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
0f0d89702d feat(search): add TagService.findByNameContaining for NL tag resolution
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
fb41affd4c docs(search): note vitest-browser workaround for + in path
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m22s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 3m47s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 24s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
Addresses @Sara review: browser tests in this spec fail silently when
the project path contains '+' (common in git worktrees). The comment
tells developers to copy the frontend directory to a clean path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:58:36 +02:00
Marcel
dc366ed403 docs(search): add detached-entity safety comment in resolveTags
Addresses @Markus review: tags fetched by findByNameContaining live outside
any transaction; Hibernate's dirty-check never fires on them. The comment
removes the ambiguity for cold readers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:58:03 +02:00
Marcel
64b7b2315d docs(search): ADR-028 fix + glossary + C4 diagram for tag resolution (#743)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m25s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 4m1s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 23:42:23 +02:00
Marcel
2a7e133717 feat(search): wire theme chip removal to URL navigation in +page.svelte
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 23:40:33 +02:00
Marcel
5387bc9247 feat(search): render removable theme chips in InterpretationChipRow
When tagsApplied is true, each resolvedTag renders as a 'Thema: Name'
chip with optional inline color style from the tag's resolved color.
Clicking × calls onRemoveChip('theme', tag.name).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 23:33:53 +02:00
Marcel
847874abb3 feat(api): add TagHint schema and extend NlQueryInterpretation with resolvedTags/tagsApplied
Manual update since Docker compose backend runs old build; regenerate with
npm run generate:api once new backend is deployed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 23:01:11 +02:00
Marcel
573bca4986 feat(i18n): add search_chip_theme_prefix to de/en/es message bundles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 22:59:58 +02:00
Marcel
86690fdbb6 test(search): DataJpaTest for descendant-expansion via TagRepository
Verifies the recursive CTE in findDescendantIdsByName expands a parent tag
to include all child IDs, and that findByNameContainingIgnoreCase matches
both parent and child names when the fragment appears in both.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 22:59:07 +02:00
Marcel
6cb1025881 test(search): add 11 tag-resolution test cases to NlQueryParserServiceTest
Covers multi-tag match, no-match FTS fallback, mixed resolution, personRole
bypass, cap at 10, short-keyword skip, dedup, rawQuery suppression when all
keywords resolve, flag independence, colour propagation via resolveEffectiveColors,
and colour=null when depth constraint prevents resolution.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 22:57:17 +02:00
Marcel
fc557bd9ae feat(search): implement keyword→tag resolution in NlQueryParserService
Keywords that substring-match the tag taxonomy become OR-union tag filters;
non-matching keywords stay as FTS text. Resolved tags surface in the
NlQueryInterpretation as TagHint objects with effective colours. The
rawQuery fallback is now guarded by hadStructuredMatch to prevent
double-apply when all keywords resolve.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 22:54:33 +02:00
Marcel
e94414b81a refactor(search): extract ChipType to chip-types.ts; audit NL fixtures
Pre-implementation step for #743: ChipType union extracted from
InterpretationChipRow and +page.svelte into shared chip-types.ts;
resolvedTags/tagsApplied neutral defaults added to test fixtures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 22:49:54 +02:00
Marcel
7eee688ce9 feat(search): extend NlQueryInterpretation with resolvedTags + tagsApplied
Positional record fields added; all 3 construction sites updated with neutral
defaults; NlQueryParserService wired for TagService (4th constructor arg);
NlQueryParserServiceTest and NlSearchControllerTest synced.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 22:37:45 +02:00
Marcel
8905135006 feat(search): add TagHint record for NL tag resolution API surface
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 22:35:24 +02:00
Marcel
8bd8390891 feat(search): add TagService.findByNameContaining for NL tag resolution
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 22:34:34 +02:00
Marcel
ed98729f75 docs(adr): record prod Ollama deployment + keep-alive decision (ADR-034)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m23s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 3m52s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 25s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
CI / Unit & Component Tests (push) Successful in 3m23s
CI / OCR Service Tests (push) Successful in 23s
CI / Backend Unit Tests (push) Successful in 3m52s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 23s
CI / Compose Bucket Idempotency (push) Successful in 1m4s
nightly / deploy-staging (push) Successful in 2m44s
Capture the why behind deploying Ollama to prod/staging compose: the
corrected init recipe (supersedes ADR-028 §10's never-functional curl
loop), the OLLAMA_KEEP_ALIVE=-1 pin (so a future maintainer doesn't
optimize it away and reintroduce the post-idle cold-load 503), the
30->60s timeout NFR, and the memswap==mem hard-OOM trade-off.

Addresses #759 review (Markus #3, Nora #2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 20:16:03 +02:00
Marcel
db87a64cc0 docs(c4): de-duplicate Ollama container in l2-containers diagram
The diagram declared Container(ollama, ...) twice — an alias collision that
renders a duplicate box. It also declared the backend->ollama relationship
twice. Keep the richer 'Ollama LLM Service' declaration and the more
specific 'NL query parsing (POST /api/generate)' relationship; drop the
duplicates.

Addresses #759 review (Markus #2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 20:14:26 +02:00
Marcel
d7d6d0638c fix(infra): make dev Ollama model-init offline-safe
Mirror the prod hardening in the dev stack: guard the model pull with
`ollama list | grep -q <model>` so an already-cached model exits clean
without a registry round-trip. Keeps dev and prod on one recipe.

Addresses #759 review (Tobias #1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 20:13:19 +02:00
Marcel
a2f37f85a6 fix(infra): make prod Ollama model-init offline-safe
The init command unconditionally ran `ollama pull`, which contacts the
registry to verify the manifest digest even when the model is already on
the volume. A host reboot during a registry/upstream-network blip would
then fail init non-zero, the `service_completed_successfully` gate would
never be met, and the ollama service (hence NL search) would stay down
until the registry was reachable again.

Guard the pull with `ollama list | grep -q <model>` so a cached model
exits clean without any registry round-trip.

Addresses #759 review (Tobias #1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 20:12:21 +02:00
Marcel
f22a1a1cfa docs(deploy): fix prod Ollama volume name to match hyphenated compose volume
docker-compose.prod.yml declares the volume as `ollama-models` (hyphen),
so the compose-project-prefixed name is `archiv-production_ollama-models`,
not the underscored `archiv-production_ollama_models` the model-upgrade
guide documented. The documented `docker volume rm` would not have matched
the real volume.

Addresses #759 review (Tobias #2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 20:09:48 +02:00
Marcel
2a0863cf3e docs(deploy): correct Ollama read timeout default to 60s
application.yaml sets app.ollama.timeout-seconds: 60 (raised from 30 to
absorb the cold model load on the first query after an Ollama restart),
but DEPLOYMENT.md still documented 30. A doc that contradicts the shipped
value is a traceability defect.

Addresses #759 review (Markus, Felix, Elicit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 20:08:55 +02:00
Marcel
9e97687d0f fix(search): pin Ollama model in memory + raise read timeout
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m18s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m55s
CI / fail2ban Regex (pull_request) Successful in 51s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
NL search recovered after deploy but went 503 again after a few minutes:
Ollama unloads the model after its default ~5 min keep-alive, so the next
query cold-loads the 4.7 GB model and exceeds the backend's 30s read
timeout (ResourceAccessException -> SMART_SEARCH_UNAVAILABLE). Warm
inference is ~18s; the cold load after idle is what timed out.

- docker-compose.{prod,yml}: set OLLAMA_KEEP_ALIVE=-1 on the ollama
  service so the model stays resident and never pays a cold-load penalty
  during normal operation (verified on staging: `ollama ps` -> UNTIL
  "Forever"; host has 47 GB free).
- application.yaml: raise app.ollama.timeout-seconds 30 -> 60 so the one
  unavoidable cold load (first query after an Ollama restart, before the
  model is pinned) completes instead of timing out.

Refs #758

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:27:02 +02:00
Marcel
b665e1132d fix(infra): deploy Ollama to prod/staging compose + fix broken model-init recipe
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m0s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 3m56s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
NL search returned 503 (SMART_SEARCH_UNAVAILABLE / "Intelligente Suche
nicht verfügbar") on staging because Ollama was never reachable.

Two defects, both downstream of #737:

1. Ollama was added only to the dev docker-compose.yml. Staging/prod
   deploy from the self-contained docker-compose.prod.yml, which had no
   ollama service — so the backend (defaulting to http://ollama:11434)
   hit a non-existent host (ResourceAccessException -> 503).

2. The merged model-init recipe never worked: the ollama/ollama image
   ENTRYPOINT is `ollama` (so `command: sh -c ...` ran as `ollama sh ...`
   -> "unknown command sh"), and the image ships no curl (so both the
   readiness loop and the healthcheck could never pass).

- docker-compose.prod.yml: add ollama-model-init + ollama services and
  the ollama-models volume, with the corrected recipe (entrypoint
  override to /bin/sh -c, `ollama list` for readiness and healthcheck).
- docker-compose.yml: fix the same broken entrypoint/command and the
  curl healthcheck so the dev stack actually starts Ollama.

Verified on staging end-to-end: model-init exits 0, ollama healthy,
backend reaches /api/tags, inference succeeds within the 8g limit.

Refs #758

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:20:22 +02:00
Marcel
87af9ab446 docs(c4): add smart-search components to l3-frontend diagram (#739 review)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m22s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m45s
CI / fail2ban Regex (pull_request) Successful in 42s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
CI / Unit & Component Tests (push) Successful in 3m19s
CI / OCR Service Tests (push) Successful in 25s
CI / Backend Unit Tests (push) Successful in 3m51s
CI / fail2ban Regex (push) Successful in 48s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m6s
Markus (architect): document SearchFilterBar + the search/ components
(SmartModeToggle, InterpretationChipRow, SmartSearchStatus,
DisambiguationPicker) and the POST /api/search/nl relation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 18:27:00 +02:00
Marcel
0058b297d8 fix(search): enlarge sub-12px text for senior legibility (#739 review)
Leonie (UX): the toggle pill (text-[7.5px]) and loading subtitle
(text-[9px]) were below the 12px floor for the 60+ audience. Bump both
to text-xs and the toggle icon to h-3.5/w-3.5. Overrides the visual
spec's tokens, which conflicted with the issue's own legibility mandate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 18:26:24 +02:00
Marcel
230f23e37c test(search): add NL search happy-path Playwright E2E (#739)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m17s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m47s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 24s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
Mock POST /api/search/nl (delayed fixture: 2-name directional + applied
keyword), assert loading announcement → chips render → axe-clean in light
and dark → removing the keyword chip re-runs a keyword GET with the
remaining sender+receiver params. Adds a data-testid wrapper on the NL
results region for axe scoping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:58:15 +02:00
Marcel
e604967a3f docs(search): document src/routes/search/ component directory (#739)
Add the smart-search sub-component directory to the frontend Project
Structure tree (merge blocker per #739).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:57:59 +02:00
Marcel
169e1ad9de test(search): cover smart-mode chip lifecycle hooks (#739)
SearchFilterBar drives chip-clearing via onModeToggle (mode switch) and
onSmartSearch (new query); pin that callback contract.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:54:25 +02:00
Marcel
f2f42ed415 feat(search): orchestrate NL search on the documents page (#739)
Lift smartMode to documents/+page.svelte and drive the full smart-search
lifecycle: POST /api/search/nl via csrfFetch, loading/error panels, chip
row, single-select disambiguation, and a transparent empty state. Chip
removal and disambiguation selection map the interpretation to keyword
params and re-run via GET (Option A in-page fallback). Mode toggle and
new queries reset prior interpretation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:54:07 +02:00
Marcel
5945824b54 feat(search): wire SmartModeToggle into SearchFilterBar (#739)
Add smartMode $bindable plus onSmartSearch/onModeToggle callbacks. The
toggle pill sits in the input's right slot (decorative icon moved to the
left); smart mode disables the live oninput keyword search, adds
maxlength=500, and submits the NL query on Enter. 4 integration specs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:47:05 +02:00
Marcel
fa41394e66 feat(search): add DisambiguationPicker single-select disclosure (#739)
Accessible disclosure: aria-expanded/aria-controls trigger, focus moves
into the option list on open, Escape and click-outside close and return
focus to the trigger, selecting a candidate emits onSelect. Single-select
(GET re-run) per the resolved #738 open decision — backend has no
multi-sender OR param. 5 vitest-browser-svelte specs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:43:27 +02:00
Marcel
fb00c7818e feat(search): add SmartSearchStatus full-area panels (#739)
Loading panel (role=status, motion-safe spinner + pulsing subtitle) and
combined error panels: 503 (red icon + switch-to-keyword button) and
429 (amber clock icon, no action button). 5 vitest-browser-svelte specs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:40:28 +02:00
Marcel
8ed65f8602 feat(search): add InterpretationChipRow component (#739)
Renders type-prefixed chips (Absender/Zeitraum/Stichwort), a single
directional chip for 2-name queries, gates keyword chips on
keywordsApplied, and emits onRemoveChip(type, value?). Truncating name
spans keep the 44px × button visible; chip wrappers show a focus ring.
9 vitest-browser-svelte specs (red/green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:38:51 +02:00
Marcel
9e425c98a1 feat(search): add SmartModeToggle pill component (#739)
Toggle pill with aria-pressed, active/resting styles matching the
AND/OR operator button pattern, and mobile-expanded KI/Text labels.
4 vitest-browser-svelte specs (red/green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:35:05 +02:00
Marcel
ddce268113 feat(search): add NL search frontend i18n keys (de/en/es)
Toggle labels, loading panel, error panels (503/429), empty-state
retry, chip type-prefixes + remove label, and disambiguation strings
for the smart search UI (#739). Formal Sie form per project standard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:32:50 +02:00
4a43962c98 Merge pull request 'feat(search): NL search backend — POST /api/search/nl with Ollama integration (#738)' (#756) from worktree-feat+issue-738-nl-search-backend into main
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m17s
CI / OCR Service Tests (push) Successful in 22s
CI / Backend Unit Tests (push) Successful in 3m43s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m4s
Reviewed-on: #756
2026-06-06 16:52:43 +02:00
Marcel
9a9e1c4c40 merge(search): resolve DEPLOYMENT.md conflict — keep setup + upgrade sections
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m17s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m45s
CI / fail2ban Regex (pull_request) Successful in 48s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
Both the first-time model pull runbook (from this branch) and the model
upgrade procedure (from main) belong in DEPLOYMENT.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 16:47:49 +02:00
Marcel
62c8ce4cb2 docs(search): add NL search visual spec — toggle pill, chips, full-area states (#739)
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / fail2ban Regex (push) Has been cancelled
CI / Semgrep Security Scan (push) Has been cancelled
CI / Compose Bucket Idempotency (push) Has been cancelled
Covers the SmartModeToggle pill (inside the search input, Google AI Mode
style), InterpretationChipRow anatomy, DisambiguationPicker, and all
status/error/empty states as full-result-area panels.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 16:47:09 +02:00
Marcel
4c620619d4 fix(search): formal Sie form in German error strings; clean up DocumentService imports
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m19s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m57s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
- error_smart_search_unavailable/rate_limited now use "Sie" (formal) to
  match the tone of all existing German error messages
- Replace inline FQNs in DocumentService.buildPersonSpec with proper
  JoinType + Predicate imports

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 16:46:40 +02:00
Marcel
44baff9c9c docs(search): update CLAUDE.md, GLOSSARY, DEPLOYMENT, and C4 diagrams
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m21s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m52s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m3s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 16:16:04 +02:00
Marcel
4634da9865 feat(search): add @Schema annotations and regenerate TypeScript API types
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 16:11:01 +02:00
Marcel
79e4a3f9db feat(search): add searchDocumentsByPersonId with Specification-based sender/receiver query
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 16:04:54 +02:00
Marcel
70e8a6e6ad feat(search): implement NlSearchController with @WebMvcTest tests (7 cases)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 15:58:35 +02:00
Marcel
3af1095d13 feat(search): implement NlQueryParserService with Mockito tests (23 cases)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 15:54:45 +02:00
Marcel
8c835e957a feat(search): implement RestClientOllamaClient with WireMock tests
Switch to wiremock-jetty12 artifact and force ee10 Jetty deps to 12.1.8
to resolve compatibility with Spring Boot 4's Jetty 12.1.8 core.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 15:43:49 +02:00
Marcel
fe8fcba7a7 feat(search): add NlSearchRateLimiter with Bucket4j/Caffeine
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 15:39:06 +02:00
Marcel
e0c80ac193 feat(search): add Ollama and rate-limit config properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 15:37:24 +02:00
Marcel
005265b5a8 feat(search): add NL search error codes and i18n strings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 15:36:13 +02:00
Marcel
684c6e63de feat(search): add NL search domain records and OllamaClient interfaces
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 15:33:56 +02:00
Marcel
e27d52b9ee docs(c4): add L3 backend search component diagram
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 15:32:40 +02:00
Marcel
6f5497c7bf docs(adr): ADR-028 — NL search via Ollama
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 15:31:53 +02:00
Marcel
e0fac783e8 feat(person): add findByDisplayNameContaining service method
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 15:30:30 +02:00
Marcel
202ea85a58 build(deps): add org.wiremock:wiremock 3.9.2 as test dependency
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 15:28:55 +02:00
Marcel
7679596c70 docs(ollama): add model upgrade runbook + post-deploy smoke test to DEPLOYMENT.md
Some checks failed
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / OCR Service Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / fail2ban Regex (pull_request) Has been cancelled
CI / Semgrep Security Scan (pull_request) Has been cancelled
CI / Compose Bucket Idempotency (pull_request) Has been cancelled
CI / Unit & Component Tests (push) Successful in 3m16s
CI / OCR Service Tests (push) Successful in 23s
CI / Backend Unit Tests (push) Successful in 3m37s
CI / fail2ban Regex (push) Successful in 47s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m4s
Addresses Elicit's and Sara's review concerns on PR #749:
- Expand §6 ollama_models section into a full model upgrade runbook (step-by-step
  docker volume rm + recreate, including production volume name prefix)
- Add re-deploy idempotency note to §3.4 (init container exits quickly when model
  already present on the volume)
- Add NL search smoke test to §3.4 (curl command distinguishing 200 from 503
  NL_SEARCH_UNAVAILABLE)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:59:35 +02:00
Marcel
3d5dcd1f18 docs(deployment): fix OLLAMA_API_KEY version ref and add --wait warning
Updated OLLAMA_API_KEY env vars table from 0.6.5 to 0.6.5 or 0.30.6 to
match both tested versions. Added an explicit warning in §3.4 that
docker compose up -d --wait blocks for 60–90 min on first deploy when the
model pull has not yet completed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:59:35 +02:00
Marcel
52fca38f0f docs(env): correct OLLAMA_API_KEY comment — tested on 0.6.5 and 0.30.6
Both versions were tested and neither enforces the key. Comment updated to
say "0.6.5 or 0.30.6" and surface archiv-net as the sole effective control.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:59:35 +02:00
Marcel
662a8f3e80 fix(infra): interpolate APP_OLLAMA_BASE_URL so .env empty-value disables Ollama
Hardcoded literal overrides any .env setting — setting APP_OLLAMA_BASE_URL=
in .env had no effect on the backend container. Now uses the same pattern
as APP_OCR_TRAINING_TOKEN with a safe default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:59:35 +02:00
Marcel
cbba95c3f8 docs(c4): fix Ollama container version 0.6.5 → 0.30.6 in l2-containers.puml
Diagram must match the pinned image version in docker-compose.yml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:59:35 +02:00
Marcel
3536ed884c docs(adr): fix ADR-028 §12 false API-key claim, stale TBD, and §7 title
§12 stated OLLAMA_API_KEY guards against lateral movement — contradicts
§7's empirical finding that it is not enforced. Replaced with an accurate
note referencing §7. Stale pre-merge placeholder in Consequences ("Three
TBD items must be resolved") removed; all three are resolved. §7 section
title updated from "0.6.5" to "0.6.5 and 0.30.6" to match the body text.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:59:35 +02:00
Marcel
5a939d9222 fix(infra): escape \$\$SERVE_PID in compose command to prevent interpolation (#737)
Docker Compose interpolates $VAR in command strings — use $$ to pass a
literal $ to the shell so SERVE_PID=$! and kill $SERVE_PID work correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:59:35 +02:00
Marcel
93e90424ab docs(adr): update ADR-028 with 0.30.6 verified findings for API key + read_only (#737)
- OLLAMA_API_KEY: non-enforcement confirmed on both 0.6.5 and 0.30.6
- read_only: true: confirmed working on both 0.6.5 and 0.30.6
- Peak RSS during pull: ~108 MiB (well under 2g limit)
- All TBD placeholders resolved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:59:35 +02:00
Marcel
e8f3004c4f feat(infra): add Ollama env vars to .env.example (#737)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:59:35 +02:00
Marcel
9637ebbca2 feat(infra): add Ollama Docker Compose services for NL search (#737)
- ollama-model-init: one-shot init container that pulls qwen2.5:7b-instruct-q4_K_M
  into the ollama_models volume on first start
- ollama: main inference service on archiv-net (expose: only, no public port)
- ollama_models named volume for persistent model storage
- APP_OLLAMA_BASE_URL + APP_OLLAMA_API_KEY added to backend env
- Both services: cap_drop ALL, no-new-privileges, read_only+tmpfs (ADR-019 + ADR-028)
- start_period: 60s — model pre-pulled by init container

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:59:35 +02:00
Marcel
df10a42069 docs(deploy): document Ollama hardware requirements, env vars, and ops notes (#737)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:59:35 +02:00
Marcel
64120a30b5 docs(arch): add Ollama container to C4 level-2 container diagram (#737)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:58:49 +02:00
Marcel
25252fc709 feat(observability): add Grafana Ollama inference latency dashboard (#737)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:58:49 +02:00
Marcel
1f379a161d fix(observability): fix OCR target name + add Ollama scrape job (#737)
- prometheus.yml: ocr:8000 → ocr-service:8000 (Docker service name is
  ocr-service, not ocr — current scrape target has never resolved)
- Add Ollama scrape job on ollama:11434 /metrics

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:58:49 +02:00
Marcel
c0d034c85d docs(adr): add ADR-028 — Ollama Docker Compose service for NL search (#737)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:58:49 +02:00
Marcel
ca93cde06e docs(infra): correct server specs — Hetzner Serverbörse i7-6700 64 GB, not CX32
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m18s
CI / OCR Service Tests (push) Successful in 21s
CI / Backend Unit Tests (push) Successful in 3m46s
CI / fail2ban Regex (push) Successful in 48s
CI / Semgrep Security Scan (push) Successful in 23s
CI / Compose Bucket Idempotency (push) Successful in 1m6s
Replace all references to the CX32 VPS (8 GB RAM, Hetzner Cloud) with the
actual production server: a Hetzner Serverbörse dedicated server with an
Intel Core i7-6700 (4C/8T, 3.4 GHz) and 64 GB RAM.

Affected files:
- .claude/personas/devops.md — monthly cost line + upgrade example
- docs/infrastructure/production-compose.md — sizing section + cost table
- docs/DEPLOYMENT.md — OCR memory table + OCR_MEM_LIMIT env var description
- docs/adr/004-pdfbox-thumbnails.md — thumbnailExecutor memory ceiling note
- docs/adr/021-tmpdir-persistent-volume-staging.md — OOMKill rationale in alternatives

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:51:07 +02:00
Marcel
7629e35897 docs(adr): renumber tag case-collision ADR 032 → 033 to resolve number clash (#731)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m15s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m40s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
CI / Unit & Component Tests (push) Successful in 3m13s
CI / OCR Service Tests (push) Successful in 23s
CI / Backend Unit Tests (push) Successful in 3m40s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 21s
CI / Compose Bucket Idempotency (push) Successful in 1m7s
Both #730 (tag case-collision) and #684 (person-delete DB integrity) landed
an ADR-032 on main. Renumber the tag/case-collision one to 033 — it is
referenced only from this PR's person-domain comments and its own file, so the
move is self-contained and touches no Flyway migration. The person-delete
ADR-032 and the V71 migration comment that cites it are deliberately left
untouched (editing an applied migration would drift its Flyway checksum).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 13:52:25 +02:00
Marcel
cd741b9f57 docs(person): clarify case-collision scope at the exact-case lookups (#731)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m15s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m42s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
Review noted the "never throws" claim was overstated: the exact-case Optional
lookups still surface a NonUniqueResultException on two byte-identical
same-case rows. That is a true data anomaly out of #731's scope (ambiguous =
case-insensitive) and resolves to the opaque INTERNAL_ERROR, never a wrong
row. Record that boundary at both resolution points and in ADR-032 so the gap
is not silently assumed covered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 13:36:22 +02:00
Marcel
ddf378aaac fix(person): resolve ambiguous sender names to null on upload (#731)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m18s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 3m38s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
findByName resolved via Optional<Person>
findByFirstNameIgnoreCaseAndLastNameIgnoreCase, which threw
NonUniqueResultException once two people shared a first+last name case-
insensitively (hans müller / Hans Müller) — a 500 on the routine upload path
(DocumentService.storeDocument sender resolution).

findByName now resolves exact-case → single case-insensitive match → else
empty. The sender path deliberately diverges from the alias path: an
ambiguous name leaves the sender UNSET rather than guessing the lowest id,
because correct provenance beats a confidently-wrong pre-fill a reviewer
won't re-check. The two new name queries use explicit HQL equality so a null
first name binds as `= NULL` (no match) instead of the derived-query fold to
`first_name IS NULL`, which would widen a last-name-only row in as a sender.

Pins the opaque error path (IncorrectResultSizeDataAccessException stays
INTERNAL_ERROR with no Hibernate/SQL/row-count leak) and extends ADR-032 with
the Person section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 13:03:04 +02:00
Marcel
20cfe41f21 fix(person): resolve case-colliding aliases without throwing (#731)
findOrCreateByAlias resolved via Optional<Person> findByAliasIgnoreCase,
which throws NonUniqueResultException once two aliases collide only by case
(müller / Müller) — a generic 500 on the importer path. Mirror the #730 tag
fix: resolve exact-case first, then the lowest-id case-insensitive sibling,
then create-when-absent (institution/group and maiden-name alias preserved).
The throwing Optional<…>IgnoreCase variant is deleted so it can't be reused.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:50:21 +02:00
43 changed files with 7427 additions and 824 deletions

View File

@@ -154,9 +154,9 @@ Schedule monthly automated restore tests. If the restore fails, the backup is wo
```
Every alert needs: description, severity, likely cause, resolution steps, escalation path.
3. **Upgrading VPS tier before profiling**
3. **Upgrading hardware before profiling**
```
# "The app feels slow" → upgrade from CX32 to CX42
# "The app feels slow" → order more RAM / a faster CPU
# Actual cause: unindexed query scanning 100k rows
```
Profile with Grafana dashboards first. Most perceived performance issues are application bugs, not resource constraints.
@@ -404,8 +404,8 @@ Hetzner Object Storage (S3-compatible, replaces MinIO in prod)
Prometheus + Loki + Alertmanager
```
### Monthly Cost: ~23 EUR
CX32 VPS (4 vCPU, 8GB RAM): 17 EUR · Object Storage (~200GB): 5 EUR · SMTP relay: ~1 EUR
### Monthly Cost: ~6 EUR (excl. server)
Hetzner dedicated server (Serverbörse, i7-6700, 64 GB RAM): see invoice · Object Storage (~200GB): 5 EUR · SMTP relay: ~1 EUR
### Reference Documentation
- Full CI workflow, Gitea vs GitHub differences: `docs/infrastructure/ci-gitea.md`

View File

@@ -41,6 +41,27 @@
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- Force WireMock's ee10 Jetty transitive deps to match Spring Boot's 12.1.8 core -->
<dependency>
<groupId>org.eclipse.jetty.ee10</groupId>
<artifactId>jetty-ee10-servlet</artifactId>
<version>12.1.8</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.ee10</groupId>
<artifactId>jetty-ee10-servlets</artifactId>
<version>12.1.8</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.ee10</groupId>
<artifactId>jetty-ee10-webapp</artifactId>
<version>12.1.8</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-ee</artifactId>
<version>12.1.8</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
@@ -137,6 +158,12 @@
<artifactId>archunit-junit5</artifactId>
<version>1.3.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.wiremock</groupId>
<artifactId>wiremock-jetty12</artifactId>
<version>3.9.2</version>
<scope>test</scope>
</dependency>
<!-- Excel Bearbeitung (Apache POI) -->
<dependency>

View File

@@ -57,6 +57,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
@EntityGraph("Document.full")
List<Document> findByReceiversId(UUID receiverId);
// Callers access only doc.getTags() to mutate the set — receivers/sender not touched; no graph needed.
List<Document> findByTags_Id(UUID tagId);

View File

@@ -32,6 +32,8 @@ import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import jakarta.persistence.criteria.JoinType;
import jakarta.persistence.criteria.Predicate;
import org.springframework.data.jpa.domain.Specification;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
@@ -1033,6 +1035,28 @@ public class DocumentService {
return documentRepository.findByReceiversId(receiverId);
}
public DocumentSearchResult searchDocumentsByPersonId(UUID personId, LocalDate from, LocalDate to, Pageable pageable) {
Person person = personService.getById(personId);
Specification<Document> spec = buildPersonSpec(person, from, to);
Page<Document> page = documentRepository.findAll(spec, pageable);
List<DocumentListItem> items = enrichItems(page.getContent(), null);
return DocumentSearchResult.paged(items, pageable, page.getTotalElements());
}
private Specification<Document> buildPersonSpec(Person person, LocalDate from, LocalDate to) {
return (root, query, cb) -> {
if (query != null) query.distinct(true);
var receiversJoin = root.join("receivers", JoinType.LEFT);
var senderPredicate = cb.equal(root.get("sender"), person);
var receiverPredicate = cb.equal(receiversJoin, person);
var personPredicate = cb.or(senderPredicate, receiverPredicate);
var predicates = new ArrayList<>(List.of(personPredicate));
if (from != null) predicates.add(cb.greaterThanOrEqualTo(root.get("documentDate"), from));
if (to != null) predicates.add(cb.lessThanOrEqualTo(root.get("documentDate"), to));
return cb.and(predicates.toArray(new Predicate[0]));
};
}
public long getIncompleteCount() {
return documentRepository.countByMetadataCompleteFalse();
}

View File

@@ -78,4 +78,8 @@ public class DomainException extends RuntimeException {
public static DomainException tooManyRequests(ErrorCode code, String message, long retryAfterSeconds) {
return new DomainException(code, HttpStatus.TOO_MANY_REQUESTS, message, retryAfterSeconds);
}
public static DomainException serviceUnavailable(ErrorCode code, String message) {
return new DomainException(code, HttpStatus.SERVICE_UNAVAILABLE, message);
}
}

View File

@@ -0,0 +1,13 @@
package org.raddatz.familienarchiv.person;
import java.util.List;
/**
* Result of {@link PersonService#resolveByName(String)}: candidate persons split by name-match
* strength. {@code direct} = every query token is a whole-token match across the person's name
* components (alias/maiden-name aware); {@code partial} = matched the substring fetch but is not
* direct. The vocabulary is deliberately name-match strength ({@code direct}/{@code partial}), not
* the search layer's resolved/ambiguous buckets — the caller maps these into its own outcome.
*/
public record NameMatches(List<Person> direct, List<Person> partial) {
}

View File

@@ -19,7 +19,8 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
"LOWER(CONCAT(COALESCE(p.firstName, ''),' ',p.lastName)) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
"LOWER(CONCAT(p.lastName, ' ', COALESCE(p.firstName, ''))) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
"LOWER(p.alias) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
"LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) " +
"LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
"LOWER(a.firstName) LIKE LOWER(CONCAT('%', :query, '%')) " +
"ORDER BY p.lastName ASC, p.firstName ASC")
List<Person> searchByName(@Param("query") String query);
@@ -29,14 +30,36 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
// Stammbaum-Knoten: alle Personen mit family_member = true.
List<Person> findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
// Lookup by full alias string, used during ODS mass import
Optional<Person> findByAliasIgnoreCase(String alias);
// Exact-case alias lookup — the first resolution step in findOrCreateByAlias.
// Case-colliding aliases across persons (müller / Müller) are valid human labels, NOT
// duplicates: source_ref is the stable identity (ADR-025/033), alias is editable. Do NOT
// add a unique(lower(alias)) constraint — see ADR-033.
Optional<Person> findByAlias(String alias);
// Plural case-insensitive alias lookup — the fallback step. Returns ALL case-folding
// siblings so the service can pick a deterministic one (lowest id) instead of letting a
// derived Optional<…>IgnoreCase throw NonUniqueResultException. See ADR-033.
List<Person> findAllByAliasIgnoreCase(String alias);
// Lookup by the normalizer person_id, used for idempotent canonical re-import (Phase 3).
Optional<Person> findBySourceRef(String sourceRef);
// Exact first+last name match, used for filename-based sender lookup
Optional<Person> findByFirstNameIgnoreCaseAndLastNameIgnoreCase(String firstName, String lastName);
// Exact-case first+last name match — the first step of filename-based sender resolution.
// Explicit `=` (HQL, not a derived query) so a null firstName binds as `first_name = NULL`
// — never a match — instead of the derived-query fold to `first_name IS NULL`, which would
// pull a last-name-only row in as a sender (a provenance defect). See ADR-033.
@Query("SELECT p FROM Person p WHERE p.firstName = :firstName AND p.lastName = :lastName")
Optional<Person> findByFirstNameAndLastName(@Param("firstName") String firstName,
@Param("lastName") String lastName);
// Plural case-insensitive first+last name match — lets findByName bail to empty on 2+ matches
// instead of letting a derived Optional<…>IgnoreCase throw NonUniqueResultException. Same
// null fail-closed guarantee as above: LOWER(:firstName) is NULL for a null arg, so a null
// first name resolves to no match (not first_name IS NULL widening). See ADR-033.
@Query("SELECT p FROM Person p WHERE LOWER(p.firstName) = LOWER(:firstName) "
+ "AND LOWER(p.lastName) = LOWER(:lastName)")
List<Person> findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(@Param("firstName") String firstName,
@Param("lastName") String lastName);
// --- PersonSummaryDTO with document count ---

View File

@@ -1,8 +1,15 @@
package org.raddatz.familienarchiv.person;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import org.springframework.lang.Nullable;
@@ -23,11 +30,20 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Service
@RequiredArgsConstructor
@Slf4j
public class PersonService {
// Co-located with the fetch loop that owns them (issue #763). MAX_TOKENS caps the number of
// unindexed leading-wildcard LIKE scans per name — a DoS control, not just perf. MAX_CANDIDATES
// bounds each result bucket and is applied AFTER classification so a direct match that sorts
// past position 10 among partials is never discarded.
private static final int MAX_TOKENS = 8;
private static final int MAX_CANDIDATES = 10;
private final PersonRepository personRepository;
private final PersonNameAliasRepository aliasRepository;
@@ -98,6 +114,96 @@ public class PersonService {
return personRepository.findAllById(ids);
}
public List<Person> findByDisplayNameContaining(String fragment) {
return personRepository.searchByName(fragment);
}
// Name-match tokenizer (issue #763): lowercase, split on whitespace/hyphen/apostrophe,
// drop empties. Applied symmetrically to the query and to every candidate name component so
// that "Anna-Maria" and "Anna Maria" tokenize alike. Order-preserving for deterministic tests.
static Set<String> tokenize(String raw) {
if (raw == null || raw.isBlank()) {
return Set.of();
}
LinkedHashSet<String> tokens = new LinkedHashSet<>();
for (String part : raw.toLowerCase(Locale.ROOT).split("[\\s\\-']+")) {
if (!part.isEmpty()) {
tokens.add(part);
}
}
return tokens;
}
/**
* Resolves an extracted person name into {@link NameMatches} by name-match strength.
* Orchestrates tokenize → cap → fetch pool → classify → cap-after-classify. Read-only
* transaction keeps the Hibernate session open so each candidate's lazy {@code nameAliases}
* are reachable during classification (see ADR-022).
*/
@Transactional(readOnly = true)
public NameMatches resolveByName(String name) {
Set<String> queryTokens = capTokens(tokenize(name));
if (queryTokens.isEmpty()) {
log.debug("resolveByName outcome=no-match tokens=0");
return new NameMatches(List.of(), List.of());
}
return classify(fetchPool(queryTokens), queryTokens);
}
private Set<String> capTokens(Set<String> tokens) {
return tokens.stream().limit(MAX_TOKENS).collect(Collectors.toCollection(LinkedHashSet::new));
}
private List<Person> fetchPool(Set<String> queryTokens) {
LinkedHashMap<UUID, Person> pool = new LinkedHashMap<>();
for (String token : queryTokens) {
for (Person candidate : findByDisplayNameContaining(token)) {
pool.putIfAbsent(candidate.getId(), candidate);
}
}
return new ArrayList<>(pool.values());
}
private NameMatches classify(List<Person> pool, Set<String> queryTokens) {
List<Person> direct = new ArrayList<>();
List<Person> partial = new ArrayList<>();
for (Person candidate : pool) {
if (personTokens(candidate).containsAll(queryTokens)) {
direct.add(candidate);
} else {
partial.add(candidate);
}
}
List<Person> cappedDirect = cap(direct);
List<Person> cappedPartial = cap(partial);
log.debug("resolveByName outcome={} tokens={}", outcome(cappedDirect, cappedPartial), queryTokens.size());
return new NameMatches(cappedDirect, cappedPartial);
}
private static Set<String> personTokens(Person person) {
Set<String> tokens = new LinkedHashSet<>();
tokens.addAll(tokenize(person.getFirstName()));
tokens.addAll(tokenize(person.getLastName()));
tokens.addAll(tokenize(person.getAlias()));
tokens.addAll(tokenize(person.getTitle()));
for (PersonNameAlias alias : person.getNameAliases()) {
tokens.addAll(tokenize(alias.getFirstName()));
tokens.addAll(tokenize(alias.getLastName()));
}
return tokens;
}
private static List<Person> cap(List<Person> people) {
return people.size() > MAX_CANDIDATES ? people.subList(0, MAX_CANDIDATES) : people;
}
private static String outcome(List<Person> direct, List<Person> partial) {
if (direct.size() == 1) return "direct=1";
if (direct.size() >= 2) return "direct>=2";
if (!partial.isEmpty()) return "partial-only";
return "no-match";
}
public List<Person> findAllFamilyMembers() {
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
}
@@ -110,7 +216,19 @@ public class PersonService {
}
public Optional<Person> findByName(String firstName, String lastName) {
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
// Same scope as findOrCreateByAlias (#731): a case-collision resolves without throwing;
// two byte-identical same-case persons are an out-of-scope data anomaly the exact
// Optional below would surface as the opaque INTERNAL_ERROR, not a wrong sender.
Optional<Person> exact = personRepository.findByFirstNameAndLastName(firstName, lastName);
if (exact.isPresent()) return exact;
List<Person> caseInsensitive =
personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
// Deliberate divergence from findOrCreateByAlias: an ambiguous filename leaves the sender
// UNSET rather than picking the lowest id. The archive's value is correct provenance — a
// confidently-wrong pre-filled "Hans Müller" is worse than an empty field, because a
// reviewer won't re-check a pre-filled value. Do NOT "consistency-clean" this into the
// lowest-id fallback. See ADR-033.
return caseInsensitive.size() == 1 ? Optional.of(caseInsensitive.get(0)) : Optional.empty();
}
/** Lookup by the normalizer person_id — used by the canonical importer for register-first matching. */
@@ -125,32 +243,45 @@ public class PersonService {
PersonType type = PersonTypeClassifier.classify(alias);
if (type == PersonType.SKIP) return null;
return personRepository.findByAliasIgnoreCase(alias).orElseGet(() -> {
if (type == PersonType.INSTITUTION || type == PersonType.GROUP) {
return personRepository.save(Person.builder()
.alias(alias)
.lastName(alias)
.personType(type)
.build());
}
// Aliases differing only by case (müller / Müller) are valid distinct persons, not
// duplicates, so a CASE-COLLISION must not throw: exact-case first, then the lowest-id
// case-insensitive sibling, then create. Mirrors the tag path — see ADR-033.
// Scope (#731): "ambiguous" means case-insensitive. Two BYTE-IDENTICAL same-case aliases
// are a true data anomaly out of scope here; the exact Optional below would surface that
// as the opaque INTERNAL_ERROR (never a wrong row), not silently pick one.
Optional<Person> exact = personRepository.findByAlias(alias);
if (exact.isPresent()) return exact.get(); // exact-case wins
List<Person> caseInsensitive = personRepository.findAllByAliasIgnoreCase(alias);
if (!caseInsensitive.isEmpty()) {
return caseInsensitive.stream().min(Comparator.comparing(Person::getId)).orElseThrow(); // deterministic tie-break — list is non-empty, never throws
}
PersonNameParser.SplitName split = PersonNameParser.split(alias);
Person person = personRepository.save(Person.builder()
// Create-when-absent: institution/group keep the full label in lastName; a person name
// is split and a maiden name (geb. …) becomes a MAIDEN_NAME alias.
if (type == PersonType.INSTITUTION || type == PersonType.GROUP) {
return personRepository.save(Person.builder()
.alias(alias)
.firstName(split.firstName())
.lastName(split.lastName())
.lastName(alias)
.personType(type)
.build());
if (split.maidenName() != null) {
int nextSortOrder = aliasRepository.findMaxSortOrder(person.getId()) + 1;
aliasRepository.save(PersonNameAlias.builder()
.person(person)
.lastName(split.maidenName())
.type(PersonNameAliasType.MAIDEN_NAME)
.sortOrder(nextSortOrder)
.build());
}
return person;
});
}
PersonNameParser.SplitName split = PersonNameParser.split(alias);
Person person = personRepository.save(Person.builder()
.alias(alias)
.firstName(split.firstName())
.lastName(split.lastName())
.build());
if (split.maidenName() != null) {
int nextSortOrder = aliasRepository.findMaxSortOrder(person.getId()) + 1;
aliasRepository.save(PersonNameAlias.builder()
.person(person)
.lastName(split.maidenName())
.type(PersonNameAliasType.MAIDEN_NAME)
.sortOrder(nextSortOrder)
.build());
}
return person;
}
/**

View File

@@ -20,8 +20,9 @@ Features: person CRUD, name alias management, person merge (deduplication), fami
| `getById(UUID)` | document, geschichte, ocr | Fetch one person by ID |
| `getAllById(List<UUID>)` | document | Bulk fetch for sender/receiver resolution |
| `findAll(String q)` | document, dashboard | List all persons |
| `findByName(String firstName, String lastName)` | document | Typeahead search |
| `findOrCreateByAlias(String rawName)` | importing | Idempotent create during mass import; type classification happens internally |
| `findByName(String firstName, String lastName)` | document | Filename-based **sender resolution** in `storeDocument`: exact-case match → single case-insensitive match → else **empty** (ambiguous names leave the sender unset; a null first name never matches). See ADR-033. |
| `resolveByName(String name)` | search | NL-search name resolution returning `NameMatches` (direct vs partial). Token/word-boundary, alias-aware matching so a single direct match auto-selects even when looser substring hits coexist ("Clara Cram" vs "Clara Cramer"). See #763. |
| `findOrCreateByAlias(String rawName)` | importing | Idempotent create during mass import; type classification happens internally. Resolves exact-case → lowest-id case-insensitive sibling → create — never throws on case-colliding aliases. See ADR-033. |
| `findAllFamilyMembers()` | dashboard | Family member list for stats |
| `findCorrespondents()` | document | Correspondent list for conversation filter |
| `count()` | dashboard | Total person count for stats |

View File

@@ -46,6 +46,10 @@ public class TagService {
return enrichWithRelatives(matched);
}
public List<Tag> findByNameContaining(String fragment) {
return tagRepository.findByNameContainingIgnoreCase(fragment);
}
public Tag getById(UUID id) {
return tagRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found: " + id));

View File

@@ -11,3 +11,4 @@ springdoc:
swagger-ui:
enabled: true
path: /swagger-ui.html

View File

@@ -624,4 +624,88 @@ class DocumentRepositoryTest {
.reviewed(reviewed)
.build();
}
// ─── searchDocumentsByPersonId (via Specification) ───────────────────────
private Page<Document> searchByPerson(Person person, LocalDate from, LocalDate to) {
Specification<Document> spec = (root, query, cb) -> {
if (query != null) query.distinct(true);
var receiversJoin = root.join("receivers", jakarta.persistence.criteria.JoinType.LEFT);
var personPredicate = cb.or(
cb.equal(root.get("sender"), person),
cb.equal(receiversJoin, person));
var predicates = new java.util.ArrayList<>(java.util.List.of(personPredicate));
if (from != null) predicates.add(cb.greaterThanOrEqualTo(root.get("documentDate"), from));
if (to != null) predicates.add(cb.lessThanOrEqualTo(root.get("documentDate"), to));
return cb.and(predicates.toArray(new jakarta.persistence.criteria.Predicate[0]));
};
return documentRepository.findAll(spec, PageRequest.of(0, 10));
}
@Test
void searchByPersonSpec_returnsDocument_whenPersonIsSender() {
Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
Document doc = documentRepository.save(Document.builder()
.title("Senderbrief").originalFilename("sender.pdf")
.status(DocumentStatus.UPLOADED).sender(person).build());
Page<Document> result = searchByPerson(person, null, null);
assertThat(result.getContent()).extracting(Document::getId).containsExactly(doc.getId());
}
@Test
void searchByPersonSpec_returnsDocument_whenPersonIsReceiver() {
Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
Document doc = documentRepository.save(Document.builder()
.title("Empfängerbrief").originalFilename("receiver.pdf")
.status(DocumentStatus.UPLOADED)
.receivers(new java.util.HashSet<>(List.of(person))).build());
Page<Document> result = searchByPerson(person, null, null);
assertThat(result.getContent()).extracting(Document::getId).containsExactly(doc.getId());
}
@Test
void searchByPersonSpec_returnsDocumentOnce_whenPersonIsBothSenderAndReceiver() {
Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
Document doc = documentRepository.save(Document.builder()
.title("SenderEmpfänger").originalFilename("both.pdf")
.status(DocumentStatus.UPLOADED).sender(person)
.receivers(new java.util.HashSet<>(List.of(person))).build());
Page<Document> result = searchByPerson(person, null, null);
assertThat(result.getContent()).hasSize(1);
assertThat(result.getContent().get(0).getId()).isEqualTo(doc.getId());
}
@Test
void searchByPersonSpec_excludesDocuments_outsideDateRange() {
Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
Document inside = documentRepository.save(Document.builder()
.title("Innen").originalFilename("inside.pdf").status(DocumentStatus.UPLOADED)
.sender(person).documentDate(LocalDate.of(1918, 6, 15)).build());
documentRepository.save(Document.builder()
.title("Außen").originalFilename("outside.pdf").status(DocumentStatus.UPLOADED)
.sender(person).documentDate(LocalDate.of(1920, 1, 1)).build());
Page<Document> result = searchByPerson(person, LocalDate.of(1914, 1, 1), LocalDate.of(1918, 12, 31));
assertThat(result.getContent()).extracting(Document::getId).containsExactly(inside.getId());
}
@Test
void searchByPersonSpec_returnsEmpty_whenNoMatchingDocuments() {
Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
Person other = personRepository.save(Person.builder().lastName("Braun").build());
documentRepository.save(Document.builder()
.title("Fremder Brief").originalFilename("other.pdf")
.status(DocumentStatus.UPLOADED).sender(other).build());
Page<Document> result = searchByPerson(person, null, null);
assertThat(result.getContent()).isEmpty();
}
}

View File

@@ -12,6 +12,7 @@ import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
@@ -37,6 +38,30 @@ class GlobalExceptionHandlerTest {
}
}
@Test
void handleGeneric_incorrectResultSize_staysOpaque_noHibernateOrRowCountLeak() {
// #731: before the fix, a case-colliding alias/name made Hibernate throw
// NonUniqueResultException → IncorrectResultSizeDataAccessException, which has no
// dedicated handler and falls through to handleGeneric. The fix removes the throw, but
// this pins the handler: a stray one must stay opaque — no Hibernate class name, no SQL,
// no "2 results were returned" row count reaching the client (CWE-209).
IncorrectResultSizeDataAccessException ex = new IncorrectResultSizeDataAccessException(
"query did not return a unique result: 2 results were returned", 1, 2);
try (MockedStatic<Sentry> sentryMock = mockStatic(Sentry.class)) {
ResponseEntity<GlobalExceptionHandler.ErrorResponse> response = handler.handleGeneric(ex);
assertThat(response.getStatusCode().value()).isEqualTo(500);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().code()).isEqualTo(ErrorCode.INTERNAL_ERROR);
assertThat(response.getBody().message())
.isEqualTo("An unexpected error occurred")
.doesNotContain("results were returned")
.doesNotContain("NonUnique")
.doesNotContain("IncorrectResultSize");
}
}
@Test
void handleDataIntegrityViolation_returns400_withoutLeakingConstraint_orSentry() {
// A DataIntegrityViolationException carries the constraint name + SQL in its message;

View File

@@ -121,37 +121,60 @@ class PersonRepositoryTest {
.containsExactly("Anna", "Clara");
}
// ─── findByAliasIgnoreCase ────────────────────────────────────────────────
// ─── findByAlias (exact) / findAllByAliasIgnoreCase (case-folding siblings) ───
@Test
void findByAliasIgnoreCase_returnsMatchingPerson() {
void findByAlias_returnsExactCaseMatchOnly() {
personRepository.save(Person.builder()
.firstName("Karl").lastName("Brandt").alias("Opa Karl").build());
Optional<Person> found = personRepository.findByAliasIgnoreCase("opa karl");
assertThat(found).isPresent();
assertThat(found.get().getFirstName()).isEqualTo("Karl");
assertThat(personRepository.findByAlias("Opa Karl")).isPresent();
assertThat(personRepository.findByAlias("opa karl")).isEmpty(); // exact-case: a folded form does NOT match
}
@Test
void findByAliasIgnoreCase_returnsEmpty_whenAliasDoesNotMatch() {
Optional<Person> found = personRepository.findByAliasIgnoreCase("nobody");
assertThat(found).isEmpty();
void findAllByAliasIgnoreCase_returnsEmpty_whenAliasDoesNotMatch() {
assertThat(personRepository.findAllByAliasIgnoreCase("nobody")).isEmpty();
}
// ─── findByFirstNameIgnoreCaseAndLastNameIgnoreCase ───────────────────────
@Test
void findAllByAliasIgnoreCase_foldsUmlautCase_inRealPostgres() {
// Proves Postgres LOWER() folds ü the same way for both rows — a plain-ASCII probe would
// stay green even if umlaut folding regressed. Both case-colliding aliases must match.
personRepository.save(Person.builder().lastName("Müller").alias("Müller").build());
personRepository.save(Person.builder().lastName("müller").alias("müller").build());
assertThat(personRepository.findAllByAliasIgnoreCase("MÜLLER")).hasSize(2);
}
// ─── findByFirstNameAndLastName (exact) / findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase ───
@Test
void findByFirstNameIgnoreCaseAndLastNameIgnoreCase_returnsMatch() {
void findByFirstNameAndLastName_returnsExactCaseMatchOnly() {
personRepository.save(Person.builder().firstName("Maria").lastName("Raddatz").build());
Optional<Person> found = personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(
"maria", "raddatz");
assertThat(personRepository.findByFirstNameAndLastName("Maria", "Raddatz")).isPresent();
assertThat(personRepository.findByFirstNameAndLastName("maria", "raddatz")).isEmpty(); // exact-case only
}
assertThat(found).isPresent();
assertThat(found.get().getFirstName()).isEqualTo("Maria");
@Test
void findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase_foldsUmlautCase_inRealPostgres() {
personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
personRepository.save(Person.builder().firstName("hans").lastName("müller").build());
assertThat(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase("HANS", "MÜLLER"))
.hasSize(2);
}
@Test
void findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase_nullFirstName_foldsToNoMatch() {
// Fail-closed: a last-name-only filename (null first name) must NOT widen to first_name IS
// NULL and pull in the institution/last-name-only row as a "sender". Proven on real
// Postgres because a mocked unit test cannot catch the IS NULL vs `= NULL` semantics.
personRepository.save(Person.builder().lastName("Müller").build()); // first_name NULL
assertThat(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(null, "Müller"))
.isEmpty();
}
// ─── findCorrespondents ───────────────────────────────────────────────────
@@ -405,6 +428,67 @@ class PersonRepositoryTest {
assertThat(results).hasSize(1);
}
@Test
void searchByName_findsByAliasFirstName() {
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
aliasRepository.save(PersonNameAlias.builder()
.person(clara).firstName("Wilhelmina").lastName("de Gruyter")
.type(PersonNameAliasType.BIRTH).sortOrder(0).build());
List<Person> results = personRepository.searchByName("Wilhelmina");
assertThat(results).hasSize(1);
assertThat(results.get(0).getLastName()).isEqualTo("Cram");
}
@Test
void searchByName_ordersByLastNameThenFirstName() {
personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
personRepository.save(Person.builder().firstName("Anna").lastName("Cram").build());
personRepository.save(Person.builder().firstName("Bernd").lastName("Cram").build());
List<Person> results = personRepository.searchByName("Cram");
assertThat(results).extracting(Person::getFirstName)
.containsExactly("Anna", "Bernd", "Clara");
}
// ─── resolveByName fetch→classify, end-to-end on real Postgres (#763 review) ───
// The classifier unit tests in PersonServiceTest stub searchByName, so they never prove the
// fetch query actually finds an alias-only match and feeds it into classification. These walk
// the whole searchByName → resolveByName path over the real Postgres slice, closing AC#4/#5.
@Test
void resolveByName_maidenAlias_classifiesAsDirect_endToEnd() {
PersonService personService = new PersonService(personRepository, aliasRepository);
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Müller").build());
aliasRepository.save(PersonNameAlias.builder()
.person(clara).lastName("Cram").type(PersonNameAliasType.MAIDEN_NAME).sortOrder(0).build());
// Detach so resolveByName re-fetches with its lazy nameAliases loaded from the DB —
// the fresh-session behaviour the @Transactional(readOnly=true) path has in production.
entityManager.flush();
entityManager.clear();
NameMatches matches = personService.resolveByName("Clara Cram");
assertThat(matches.direct()).extracting(Person::getId).containsExactly(clara.getId());
}
@Test
void resolveByName_aliasFirstName_classifiesAsDirect_endToEnd() {
PersonService personService = new PersonService(personRepository, aliasRepository);
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
aliasRepository.save(PersonNameAlias.builder()
.person(clara).firstName("Wilhelmina").lastName("de Gruyter")
.type(PersonNameAliasType.BIRTH).sortOrder(0).build());
entityManager.flush();
entityManager.clear();
NameMatches matches = personService.resolveByName("Wilhelmina");
assertThat(matches.direct()).extracting(Person::getId).containsExactly(clara.getId());
}
// ─── searchWithDocumentCount with aliases ────────────────────────────────
@Test

View File

@@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonType;
@@ -16,10 +17,13 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.annotation.Transactional;
import software.amazon.awssdk.services.s3.S3Client;
import org.springframework.mock.web.MockMultipartFile;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@@ -33,6 +37,7 @@ class PersonServiceIntegrationTest {
@Autowired PersonService personService;
@Autowired PersonRepository personRepository;
@Autowired DocumentRepository documentRepository;
@Autowired DocumentService documentService;
@PersistenceContext EntityManager entityManager;
@@ -75,6 +80,93 @@ class PersonServiceIntegrationTest {
assertThat(result.getLastName()).isEqualTo("Cram");
}
// ─── #731: case-colliding alias resolution against real Postgres ───────────
// The umlaut pair is mandatory — only the real DB proves Postgres LOWER() folds ü; a
// plain-ASCII test would stay green while umlaut aliases regressed.
@Test
void findOrCreateByAlias_resolvesUmlautAliasCollision_toLowestId_withoutThrow() {
Person muller = personRepository.save(Person.builder().lastName("Müller").alias("Müller").build());
Person mullerLower = personRepository.save(Person.builder().lastName("müller").alias("müller").build());
UUID expected = muller.getId().compareTo(mullerLower.getId()) <= 0 ? muller.getId() : mullerLower.getId();
// No exact-case "MÜLLER" row → falls through to the case-insensitive branch with two
// candidates and must pick the lowest id, never throwing NonUniqueResultException.
Person resolved = personService.findOrCreateByAlias("MÜLLER");
assertThat(resolved.getId()).isEqualTo(expected);
}
@Test
void findOrCreateByAlias_umlautAliasCollision_isDeterministicAcrossCalls() {
personRepository.save(Person.builder().lastName("Müller").alias("Müller").build());
personRepository.save(Person.builder().lastName("müller").alias("müller").build());
Person first = personService.findOrCreateByAlias("MÜLLER");
Person second = personService.findOrCreateByAlias("MÜLLER");
assertThat(second.getId()).isEqualTo(first.getId());
}
// ─── #731: filename-based sender resolution against real Postgres ──────────
@Test
void storeDocument_resolvesSender_whenFilenameNameIsUnique() throws Exception {
Person hans = personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
Document doc = uploadNamed("1965-03-12_Müller_Hans.pdf").document();
assertThat(doc.getSender()).isNotNull();
assertThat(doc.getSender().getId()).isEqualTo(hans.getId());
}
@Test
void storeDocument_resolvesSender_onSingleCaseInsensitiveMatch() throws Exception {
Person hans = personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
// Filename folds to "hans müller"; the only stored person is "Hans Müller".
Document doc = uploadNamed("1965-03-12_müller_hans.pdf").document();
assertThat(doc.getSender()).isNotNull();
assertThat(doc.getSender().getId()).isEqualTo(hans.getId());
}
@Test
void storeDocument_leavesSenderUnset_whenFilenameNameIsAmbiguous() throws Exception {
// Two persons collide case-insensitively; the filename casing ("HANS"/"MÜLLER") matches
// neither exactly → no exact-case winner → bail to null (never an arbitrary guess), no 500.
personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
personRepository.save(Person.builder().firstName("hans").lastName("müller").build());
Document doc = uploadNamed("1965-03-12_MÜLLER_HANS.pdf").document();
assertThat(doc.getSender()).isNull();
}
@Test
void storeDocument_leavesSenderUnset_whenFilenameHasNoFirstName() throws Exception {
// A last-name-only filename never resolves to a sender (the parser yields no parsed name).
personRepository.save(Person.builder().lastName("Müller").build());
Document doc = uploadNamed("1965-03-12_Müller.pdf").document();
assertThat(doc.getSender()).isNull();
}
@Test
void findByName_nullFirstName_resolvesToEmpty_inRealPostgres() {
// Fail-closed against the real DB: a null first name must NOT widen to first_name IS NULL
// and pick up the last-name-only row.
personRepository.save(Person.builder().lastName("Müller").build()); // first_name NULL
assertThat(personService.findByName(null, "Müller")).isEmpty();
}
private DocumentService.StoreResult uploadNamed(String filename) throws Exception {
MockMultipartFile file = new MockMultipartFile("file", filename, "application/pdf", new byte[]{1, 2, 3});
return documentService.storeDocument(file, null);
}
// ─── #667: confirm round-trip + reader-default semantics ──────────────────
@Test

View File

@@ -375,14 +375,57 @@ class PersonServiceTest {
// ─── findOrCreateByAlias ─────────────────────────────────────────────────
@Test
void findOrCreateByAlias_returnsExisting_whenAliasFound() {
String alias = "Walter de Gruyter";
Person existing = Person.builder().id(UUID.randomUUID()).alias(alias).build();
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.of(existing));
void findOrCreateByAlias_returnsExactCaseMatch_overCaseInsensitiveSibling() {
String alias = "müller";
Person exact = Person.builder().id(UUID.randomUUID()).alias("müller").build();
when(personRepository.findByAlias(alias)).thenReturn(Optional.of(exact));
Person result = personService.findOrCreateByAlias(alias);
assertThat(result).isEqualTo(existing);
assertThat(result).isEqualTo(exact);
verify(personRepository, never()).findAllByAliasIgnoreCase(any());
verify(personRepository, never()).save(any());
}
@Test
void findOrCreateByAlias_returnsExactCaseMatch_evenWhenMultipleSiblingsCollide() {
String alias = "Müller";
Person exact = Person.builder().id(UUID.randomUUID()).alias("Müller").build();
when(personRepository.findByAlias(alias)).thenReturn(Optional.of(exact));
Person result = personService.findOrCreateByAlias(alias);
assertThat(result).isEqualTo(exact);
// exact-case short-circuits — the case-insensitive siblings are never consulted.
verify(personRepository, never()).findAllByAliasIgnoreCase(any());
}
@Test
void findOrCreateByAlias_usesSingleCaseInsensitiveMatch_whenNoExactCase() {
String alias = "müller";
Person only = Person.builder().id(UUID.randomUUID()).alias("Müller").build();
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of(only));
Person result = personService.findOrCreateByAlias(alias);
assertThat(result).isEqualTo(only);
verify(personRepository, never()).save(any());
}
@Test
void findOrCreateByAlias_returnsLowestIdDeterministically_whenMultipleCaseInsensitiveMatches() {
String alias = "müller";
Person lower = Person.builder().id(UUID.fromString("00000000-0000-0000-0000-000000000001")).alias("Müller").build();
Person higher = Person.builder().id(UUID.fromString("00000000-0000-0000-0000-000000000002")).alias("müller").build();
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of(higher, lower)); // unordered
Person first = personService.findOrCreateByAlias(alias);
Person second = personService.findOrCreateByAlias(alias);
assertThat(first.getId()).isEqualTo(lower.getId()); // lowest id wins
assertThat(second.getId()).isEqualTo(first.getId()); // same result every call — never throws
verify(personRepository, never()).save(any());
}
@@ -390,7 +433,8 @@ class PersonServiceTest {
void findOrCreateByAlias_createsNew_whenAliasNotFound() {
String alias = "Clara Cram";
Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of());
when(personRepository.save(any())).thenReturn(saved);
Person result = personService.findOrCreateByAlias(alias);
@@ -403,7 +447,8 @@ class PersonServiceTest {
void findOrCreateByAlias_createsMaidenNameAlias_whenGebPresent() {
String alias = "Clara Cram geb. de Gruyter";
Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of());
when(personRepository.save(any())).thenReturn(saved);
when(aliasRepository.findMaxSortOrder(saved.getId())).thenReturn(0);
when(aliasRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
@@ -425,7 +470,8 @@ class PersonServiceTest {
@Test
void findOrCreateByAlias_setsInstitutionType_withFullNameInLastName() {
String alias = "Arthur Collignon GmbH";
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of());
when(personRepository.save(any())).thenAnswer(inv -> {
Person p = inv.getArgument(0);
p.setId(UUID.randomUUID());
@@ -442,7 +488,8 @@ class PersonServiceTest {
@Test
void findOrCreateByAlias_setsGroupType_withFullNameInLastName() {
String alias = "Geschwister de Gruyter";
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of());
when(personRepository.save(any())).thenAnswer(inv -> {
Person p = inv.getArgument(0);
p.setId(UUID.randomUUID());
@@ -460,7 +507,8 @@ class PersonServiceTest {
void findOrCreateByAlias_noAlias_whenNoGeb() {
String alias = "Clara Cram";
Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of());
when(personRepository.save(any())).thenReturn(saved);
personService.findOrCreateByAlias(alias);
@@ -472,11 +520,54 @@ class PersonServiceTest {
void findOrCreateByAlias_trimsInput() {
String alias = " Clara Cram ";
Person saved = Person.builder().id(UUID.randomUUID()).alias("Clara Cram").build();
when(personRepository.findByAliasIgnoreCase("Clara Cram")).thenReturn(Optional.of(saved));
when(personRepository.findByAlias("Clara Cram")).thenReturn(Optional.of(saved));
personService.findOrCreateByAlias(alias);
verify(personRepository).findByAliasIgnoreCase("Clara Cram");
verify(personRepository).findByAlias("Clara Cram");
}
// ─── findByName (filename-based sender resolution) ────────────────────────
@Test
void findByName_returnsExactCaseMatch_overCaseInsensitiveSibling() {
Person exact = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
when(personRepository.findByFirstNameAndLastName("Hans", "Müller")).thenReturn(Optional.of(exact));
assertThat(personService.findByName("Hans", "Müller")).contains(exact);
verify(personRepository, never()).findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(any(), any());
}
@Test
void findByName_usesSingleCaseInsensitiveMatch_whenNoExactCase() {
Person only = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
when(personRepository.findByFirstNameAndLastName("hans", "müller")).thenReturn(Optional.empty());
when(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase("hans", "müller"))
.thenReturn(List.of(only));
assertThat(personService.findByName("hans", "müller")).contains(only);
}
@Test
void findByName_bailsToEmpty_whenTwoOrMoreCaseInsensitiveMatches() {
Person a = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
Person b = Person.builder().id(UUID.randomUUID()).firstName("hans").lastName("müller").build();
when(personRepository.findByFirstNameAndLastName("hans", "müller")).thenReturn(Optional.empty());
when(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase("hans", "müller"))
.thenReturn(List.of(a, b));
// Ambiguous sender → unset, never an arbitrary guess (provenance correctness over a
// confidently-wrong pre-fill). This is the deliberate divergence from the alias path.
assertThat(personService.findByName("hans", "müller")).isEmpty();
}
@Test
void findByName_returnsEmpty_whenFirstNameNullFoldsToNoMatch() {
when(personRepository.findByFirstNameAndLastName(null, "Müller")).thenReturn(Optional.empty());
when(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(null, "Müller"))
.thenReturn(List.of());
assertThat(personService.findByName(null, "Müller")).isEmpty();
}
// ─── updatePerson (notes) ────────────────────────────────────────────────
@@ -807,4 +898,165 @@ class PersonServiceTest {
.extracting(e -> ((DomainException) e).getStatus().value())
.isEqualTo(403);
}
@Test
void findByDisplayNameContaining_delegatesToSearchByName() {
Person walter = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("Müller").build();
when(personRepository.searchByName("Walter")).thenReturn(List.of(walter));
List<Person> result = personService.findByDisplayNameContaining("Walter");
assertThat(result).containsExactly(walter);
verify(personRepository).searchByName("Walter");
}
// ─── tokenize (name-match contract) ───────────────────────────────────────
@Test
void tokenize_hyphenatedName_splitsOnHyphen() {
assertThat(PersonService.tokenize("Anna-Maria")).containsExactly("anna", "maria");
}
@Test
void tokenize_apostropheName_splitsOnApostrophe() {
assertThat(PersonService.tokenize("D'Angelo")).containsExactly("d", "angelo");
}
@Test
void tokenize_umlautName_lowercasesToSingleToken() {
assertThat(PersonService.tokenize("Müller")).containsExactly("müller");
}
@Test
void tokenize_doubleSpace_dropsEmptyTokens() {
assertThat(PersonService.tokenize("Clara Cram")).containsExactly("clara", "cram");
}
@Test
void tokenize_allWhitespace_returnsEmpty() {
assertThat(PersonService.tokenize(" ")).isEmpty();
}
@Test
void tokenize_null_returnsEmpty() {
assertThat(PersonService.tokenize(null)).isEmpty();
}
// ─── resolveByName (direct / partial classification) ──────────────────────
@Test
void resolveByName_singleDirectMatch_classifiesAsDirect() {
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
NameMatches result = personService.resolveByName("Clara Cram");
assertThat(result.direct()).containsExactly(clara);
}
@Test
void resolveByName_maidenAliasToken_classifiesAsDirect() {
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Müller")
.nameAliases(List.of(PersonNameAlias.builder().lastName("Cram")
.type(PersonNameAliasType.MAIDEN_NAME).build()))
.build();
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
NameMatches result = personService.resolveByName("Clara Cram");
assertThat(result.direct()).containsExactly(clara);
}
@Test
void resolveByName_aliasFirstNameToken_isFetchedAndClassified() {
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram")
.nameAliases(List.of(PersonNameAlias.builder().firstName("Wilhelmina").lastName("de Gruyter")
.type(PersonNameAliasType.BIRTH).build()))
.build();
when(personRepository.searchByName("wilhelmina")).thenReturn(List.of(clara));
NameMatches result = personService.resolveByName("Wilhelmina");
assertThat(result.direct()).containsExactly(clara);
}
@Test
void resolveByName_middleName_stillDirect() {
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara Maria").lastName("Cram").build();
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
NameMatches result = personService.resolveByName("Clara Cram");
assertThat(result.direct()).containsExactly(clara);
}
@Test
void resolveByName_reorderedTokens_stillDirect() {
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
NameMatches result = personService.resolveByName("Cram Clara");
assertThat(result.direct()).containsExactly(clara);
}
@Test
void resolveByName_cramVsCramer_classifiesAsPartial() {
Person cramer = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cramer").build();
when(personRepository.searchByName("clara")).thenReturn(List.of(cramer));
when(personRepository.searchByName("cram")).thenReturn(List.of(cramer));
NameMatches result = personService.resolveByName("Clara Cram");
assertThat(result.partial()).containsExactly(cramer);
}
@Test
void resolveByName_emptyAfterTokenizing_returnsNoCandidates() {
NameMatches result = personService.resolveByName(" - ");
assertThat(result.direct()).isEmpty();
verify(personRepository, never()).searchByName(any());
}
@Test
void resolveByName_directSortsBeyondCap_stillReturnedAsDirect() {
List<Person> pool = new java.util.ArrayList<>();
for (int i = 0; i < 10; i++) {
pool.add(Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cramer").build());
}
Person direct = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
pool.add(direct);
when(personRepository.searchByName("clara")).thenReturn(pool);
when(personRepository.searchByName("cram")).thenReturn(pool);
NameMatches result = personService.resolveByName("Clara Cram");
assertThat(result.direct()).containsExactly(direct);
}
@Test
void resolveByName_over8Tokens_issuesAtMost8Fetches() {
personService.resolveByName("a b c d e f g h i j");
verify(personRepository, org.mockito.Mockito.atMost(8)).searchByName(any());
}
@Test
void resolveByName_samePersonFromTwoTokens_appearsOnce() {
// Both token fetches return the same person id — fetchPool's putIfAbsent must dedup so the
// candidate is classified once, not twice.
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
NameMatches result = personService.resolveByName("Clara Cram");
assertThat(result.direct()).hasSize(1);
assertThat(result.partial()).isEmpty();
}
}

View File

@@ -666,4 +666,17 @@ class TagServiceTest {
// verify findAllById was called at least twice: once for extras, once inside resolveEffectiveColors
verify(tagRepository, atLeastOnce()).findAllById(any());
}
// ─── findByNameContaining ─────────────────────────────────────────────────
@Test
void findByNameContaining_delegatesToRepository() {
Tag krieg = Tag.builder().id(UUID.randomUUID()).name("Krieg").build();
when(tagRepository.findByNameContainingIgnoreCase("krieg")).thenReturn(List.of(krieg));
List<Tag> result = tagService.findByNameContaining("krieg");
assertThat(result).containsExactly(krieg);
verify(tagRepository).findByNameContainingIgnoreCase("krieg");
}
}

View File

@@ -141,65 +141,6 @@ services:
security_opt:
- no-new-privileges:true
# --- Ollama: Model init (one-shot pull) ---
# Pulls qwen2.5:7b-instruct-q4_K_M (~4.7 GB) into the ollama_models volume on first start.
# On subsequent starts (model already in volume), exits quickly without re-downloading.
# Not started in CI — CI uses explicit service selection
# (docker-compose.ci.yml: db minio create-buckets)
ollama-model-init:
image: ollama/ollama:0.30.6
restart: "no"
networks:
- archiv-net
volumes:
- ollama_models:/root/.ollama
mem_limit: 2g
read_only: true
tmpfs:
- /tmp:size=512m
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
command: >
sh -c "ollama serve & SERVE_PID=$$! && until curl -sf http://localhost:11434/api/tags; do sleep 1; done && ollama pull qwen2.5:7b-instruct-q4_K_M && kill $$SERVE_PID"
# --- Ollama: LLM inference server ---
# Serves the pre-pulled model for NL search inference.
# Not started in CI — CI uses explicit service selection
# (docker-compose.ci.yml: db minio create-buckets)
ollama:
image: ollama/ollama:0.30.6
container_name: archive-ollama
restart: unless-stopped
expose:
- "11434"
networks:
- archiv-net
volumes:
- ollama_models:/root/.ollama
environment:
OLLAMA_API_KEY: "${OLLAMA_API_KEY}"
cpus: "${OLLAMA_CPU_LIMIT:-4.0}"
mem_limit: "${OLLAMA_MEM_LIMIT:-8g}"
memswap_limit: "${OLLAMA_MEM_LIMIT:-8g}"
read_only: true
tmpfs:
- /tmp:size=512m
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s # model weights are pre-loaded by ollama-model-init; service only needs to bind port
depends_on:
ollama-model-init:
condition: service_completed_successfully
# --- Backend: Spring Boot ---
backend:
build:
@@ -243,8 +184,6 @@ services:
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: ${MAIL_STARTTLS_ENABLE:-false}
APP_OCR_BASE_URL: http://ocr-service:8000
APP_OCR_TRAINING_TOKEN: "${OCR_TRAINING_TOKEN:-}"
APP_OLLAMA_BASE_URL: "${APP_OLLAMA_BASE_URL:-http://ollama:11434}"
APP_OLLAMA_API_KEY: "${OLLAMA_API_KEY}"
SENTRY_DSN: ${SENTRY_DSN:-}
SENTRY_TRACES_SAMPLE_RATE: ${SENTRY_TRACES_SAMPLE_RATE:-1.0}
# Observability: send traces to Tempo inside archiv-net (OTLP gRPC port 4317)
@@ -308,4 +247,3 @@ volumes:
frontend_node_modules:
ocr_models:
ocr_cache:
ollama_models:

View File

@@ -50,15 +50,14 @@ graph TD
The OCR service requires significant RAM for model loading. The dev compose sets `mem_limit: 12g`.
| Production target | RAM | Recommended OCR limit | NL Search | Notes |
|---|---|---|---|---|
| Hetzner CX42 | 16 GB | 12 GB | Supported (Ollama 8 GB + OCR 6 GB active ≈ 14 GB) | Recommended for OCR-enabled production |
| Hetzner CX32 | 8 GB | 6 GB | Disabled — set `APP_OLLAMA_BASE_URL=` (empty) | Accept reduced batch sizes and slower throughput |
| Hetzner CX22 | 4 GB | — | Unsupported | Disable the OCR service (`profiles: [ocr]`); run OCR on demand only |
| Production target | RAM | Recommended OCR limit | Notes |
|---|---|---|---|
| Current server (Hetzner Serverbörse, i7-6700) | 64 GB | 12 GB | Default `mem_limit: 12g` works comfortably |
| ≥ 16 GB RAM | 16+ GB | 12 GB | Default works |
| 8 GB RAM | 8 GB | 6 GB | Set `OCR_MEM_LIMIT=6g`; accept reduced batch sizes |
| 4 GB RAM | 4 GB | — | Disable OCR service (`profiles: [ocr]`); run OCR on demand only |
A CX32 cannot honour the default `mem_limit: 12g` — set the `OCR_MEM_LIMIT=6g` env var (in `.env.production` / `.env.staging`, or as a Gitea secret consumed by the workflow) before deploying on a CX32. The prod compose interpolates this var with a 12g default.
> **Memory budget (CX42):** OCR (~6 GB active) + Ollama (~8 GB) = ~14 GB. Do not run `docker-compose.observability.yml` continuously alongside both services on a CX42.
On servers with less than 16 GB RAM the default `mem_limit: 12g` cannot be honoured — set the `OCR_MEM_LIMIT` env var (in `.env.production` / `.env.staging`, or as a Gitea secret consumed by the workflow). The prod compose interpolates this var with a 12g default.
### Dev vs production differences
@@ -142,20 +141,10 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
| `ALLOWED_PDF_HOSTS` | SSRF protection — comma-separated list of allowed PDF source hosts. **Do not widen to `*`** | `minio,localhost,127.0.0.1` | YES | — |
| `KRAKEN_MODEL_PATH` | Directory containing Kraken HTR models (populated by `download-kraken-models.sh`) | `/app/models/` | — | — |
| `BLLA_MODEL_PATH` | Kraken baseline layout analysis model path | `/app/models/blla.mlmodel` | — | — |
| `OCR_MEM_LIMIT` | Container memory cap for ocr-service in `docker-compose.prod.yml`. Set to `6g` on CX32 hosts; leave unset on CX42+ to use the 12g default | `12g` (prod compose default) | — | — |
| `OCR_MEM_LIMIT` | Container memory cap for ocr-service in `docker-compose.prod.yml`. Set to `6g` on servers with 8 GB RAM; leave unset (12g default) on servers with ≥ 16 GB RAM | `12g` (prod compose default) | — | — |
| `XDG_CACHE_HOME` | XDG cache base dir — redirects Matplotlib and other XDG-aware libraries away from the read-only `HOME` (`/home/ocr`) to the writable cache volume | `/app/cache` | — | — |
| `TORCH_HOME` | PyTorch model cache — redirects `~/.cache/torch` to the writable models volume | `/app/models/torch` | — | — |
### Ollama (NL search) service
| Variable | Purpose | Default | Required? | Sensitive? |
|---|---|---|---|---|
| `APP_OLLAMA_BASE_URL` | Base URL for the Ollama service. Leave empty to disable NL search. | `http://ollama:11434` | — | — |
| `APP_OLLAMA_API_KEY` | API key passed as `Authorization: Bearer` to Ollama. Leave empty for unauthenticated access. Note: `OLLAMA_API_KEY` is not enforced in Ollama 0.6.5 or 0.30.6 (see ADR-028). | — | — | YES |
| `OLLAMA_CPU_LIMIT` | Docker CPU quota for the Ollama container. On CX42 (8 vCPUs) can be raised to `7.5`. | `4.0` | — | — |
| `OLLAMA_MEM_LIMIT` | Memory limit for the Ollama container. Requires CX42 (16 GB RAM). | `8g` | — | — |
| `OLLAMA_API_KEY` | API key set on the Ollama service itself. Same value as `APP_OLLAMA_API_KEY`. Leave empty for unauthenticated. | — | — | YES |
### Observability stack (`docker-compose.observability.yml`)
| Variable | Purpose | Default | Required? | Sensitive? |
@@ -276,18 +265,6 @@ git.raddatz.cloud A <server IP>
### 3.4 First deploy
> **First start — Ollama model pull:** On first `docker compose up -d`, the `ollama-model-init` container pulls `qwen2.5:7b-instruct-q4_K_M` (~4.7 GB). At 10 Mbps this takes approximately 6090 minutes; at 100 Mbps approximately 610 minutes. The pull is a one-time operation — subsequent restarts skip it (model already on the `ollama_models` volume). Monitor progress with `docker logs -f $(docker ps -q --filter name=ollama-model-init)`.
>
> **Do not use `--wait` on first deploy** — `docker compose up -d --wait` waits for all services to reach their health/completion target, including `ollama-model-init`. On first pull this blocks for 6090 minutes and will time out any CI/deploy script that uses `--wait`.
>
> **Re-deploy idempotency:** on subsequent `docker compose up -d` runs (including `--force-recreate`), `ollama-model-init` re-executes but exits in seconds — Ollama's CLI skips the download when the model digest already matches what is on the volume.
>
> **Verify NL search is active** after enabling Ollama (`APP_OLLAMA_BASE_URL=http://ollama:11434`):
> ```bash
> curl -s http://localhost:8080/api/nl-search?q=brief+von+grossmutter
> # Returns 200 with results → NL search is active
> # Returns 503 NL_SEARCH_UNAVAILABLE → Ollama is not reachable or APP_OLLAMA_BASE_URL is unset
> ```
```bash
# 1. Trigger nightly.yml manually (Repo → Actions → nightly → "Run workflow")
@@ -584,24 +561,6 @@ bash scripts/download-kraken-models.sh
> Downloads the Kurrent/Sütterlin HTR models. Run once after a fresh clone or when models are updated.
### Upgrade the Ollama model
To switch to a newer model version (e.g. a future release of `qwen2.5`):
1. Update the model name in the `ollama-model-init` `command:` in `docker-compose.yml`.
2. Remove the existing model volume to free the old weights:
```bash
docker volume rm familienarchiv_ollama_models
```
(In production the volume name is prefixed with the compose project: `archiv-production_ollama_models`.)
3. Restart the stack:
```bash
docker compose up -d
```
The `ollama-model-init` container pulls the new model weights on first start (~48 GB download depending on the model). The `ollama` inference server will not start until the pull completes (`condition: service_completed_successfully`).
> **`ollama_models` volume:** holds model weights only — fully reproducible by re-pull, no backup needed.
### Trigger a canonical import
The importer no longer parses the raw spreadsheet. It consumes the **canonical artifacts**

View File

@@ -165,6 +165,8 @@ _See also [Chronik](#chronik-internal)._
**Domain** — a Tier-1 bounded context with its own entities, controller, service, repository, and DTOs. Backend domains: `document`, `person`, `tag`, `user`, `geschichte`, `notification`, `ocr`, `audit`, `dashboard`. Frontend domains mirror this structure under `src/lib/`.
**NameMatches** — the Person-domain result of `PersonService.resolveByName(name)`: candidate persons split by name-match strength into `direct` and `partial`. A match is **direct** when every query token is a whole-token match (order-independent, alias/maiden-name aware) across all of a person's name components (`firstName`, `lastName`, `alias`, each `PersonNameAlias` first+last, `title`); a **partial** matched the substring fetch but is not direct (e.g. "Cram" → "Clara Cramer").
---
## Infrastructure Terms

View File

@@ -35,7 +35,7 @@ Render thumbnails in-process in Spring Boot using **Apache PDFBox 3.0.4** (alrea
**Harder:**
- PDFBox is a parser attack surface. Mitigated by a 30-second watchdog timeout in `ThumbnailAsyncRunner` and by the fire-and-forget contract (failures never break upload).
- Memory ceiling: the `thumbnailExecutor` is capped at 2 threads on the CX32 (8 GB). A busy backfill alongside OCR can approach the 3 GB heap — acceptable but not comfortable. Streaming via `FileService.downloadFileStream` keeps this bounded for PDFs up to 50 MB.
- Memory ceiling: the `thumbnailExecutor` is capped at 2 threads on memory-constrained hosts. A busy backfill alongside OCR can approach the 3 GB heap on an 8 GB server — acceptable but not comfortable. The current production server (64 GB) has ample headroom. Streaming via `FileService.downloadFileStream` keeps this bounded for PDFs up to 50 MB.
### Operational caveats (intentional)

View File

@@ -62,7 +62,7 @@ The `/tmp` tmpfs remains at 512 MB and continues to serve training-ZIP extractio
## Alternatives considered
**Approach B — Enlarge `/tmp` to 4 GB**
One-line change. Discarded because: (1) 4 GB tmpfs counts against the cgroup `mem_limit`; on CX32 hosts with `OCR_MEM_LIMIT=6g` the combined Surya resident set + tmpfs would trigger OOMKill on cold start; (2) staging GB-scale model files through RAM is using the wrong storage tier; (3) any future model larger than 4 GB requires another bump.
One-line change. Discarded because: (1) 4 GB tmpfs counts against the cgroup `mem_limit`; on servers with `OCR_MEM_LIMIT=6g` the combined Surya resident set + tmpfs would trigger OOMKill on cold start; (2) staging GB-scale model files through RAM is using the wrong storage tier; (3) any future model larger than 4 GB requires another bump.
**Approach C — Both TMPDIR redirect and enlarged /tmp**
Belt-and-suspenders: Approach A + 1 GB tmpfs. Discarded in favour of the cleaner Approach A. The defence-in-depth benefit does not outweigh the extra compose churn; the 512 MB cap on `/tmp` is intentional.

View File

@@ -1,239 +0,0 @@
# ADR-028: Ollama Docker Compose service for NL search
**Date:** 2026-06-06
**Status:** Accepted
**Deciders:** Marcel Raddatz
**Relates to:** #737 (infrastructure), #735 (NL search epic)
---
## Context
Issue #735 introduces natural-language document search, requiring a local LLM to generate embeddings and/or run inference at query time. The family archive stores personal family history — data privacy is non-negotiable, so cloud-based inference APIs are excluded. The production target is a Hetzner CX42 (16 GB RAM, 8 vCPUs, CPU-only, ~32 EUR/month).
Alternatives considered:
| Option | Reason rejected |
|---|---|
| **llama.cpp** | No HTTP API out of the box; requires custom wrapper; higher ops burden |
| **vLLM** | GPU-first; significant overhead on CPU-only hardware; overkill for this scale |
| **Cloud APIs** (OpenAI, Gemini, etc.) | Vendor lock-in; per-token cost at scale; data leaves the server — unacceptable for a private family archive |
| **Ollama** | Self-contained Docker image; built-in HTTP REST API; actively maintained; CPU-compatible; zero egress |
**Decision:** run Ollama as a Docker Compose service alongside the existing stack.
---
## Decisions
### 1. Hardware minimums and CPU-only constraint
All inference runs on CPU. The target is the Hetzner CX42 (16 GB RAM, 8 vCPUs).
| Tier | RAM | NL search |
|---|---|---|
| CX42 | 16 GB | Supported — full stack including Ollama |
| CX32 | 8 GB | Disabled — set `APP_OLLAMA_BASE_URL=` (empty) to skip Ollama entirely |
| CX22 | 4 GB | Unsupported for NL search |
### 2. Memory budget on CX42
| Component | `mem_limit` | Typical active RSS |
|---|---|---|
| OCR service | 12g (hard ceiling) | ~6 GB |
| Ollama | 8g | ~8 GB |
| **Total** | | **~14 GB active** |
`memswap_limit` on the Ollama service is set to `8g` (matching `mem_limit`) to prevent Linux from swapping model weights into swap under OCR memory pressure. Swapping model weights does not crash the container but silently degrades inference latency. This mirrors the pattern already applied to the OCR service.
**Operational constraint:** do NOT run `docker-compose.observability.yml` continuously alongside both OCR and Ollama on a CX42. The observability stack adds ~2 GB, which leaves no headroom.
### 3. Graceful-degradation contract
`app.ollama.base-url` absent OR blank → Ollama bean NOT registered → NL search returns HTTP 503 with `ErrorCode: NL_SEARCH_UNAVAILABLE`.
This single code path covers all unavailability scenarios: base-url unset, service unreachable, health check failed, and request timeout.
#### Why not `@ConditionalOnProperty`
`@ConditionalOnProperty` registers the bean when the property is present but blank (`APP_OLLAMA_BASE_URL=`). This produces a `RestClient` with an empty base URL that fails at runtime with an opaque error rather than a clean 503.
#### Correct condition expression
```java
@ConditionalOnExpression("!'${app.ollama.base-url:}'.isBlank()")
```
When the property is absent, the placeholder resolves to `''`; `.isBlank()` returns `true`; negation makes the condition `false`; the bean is not registered. Same result for an explicit empty string (`APP_OLLAMA_BASE_URL=`).
### 4. Backend configuration pattern
Use a `@ConfigurationProperties` record, not separate `@Value` injections:
```java
@ConfigurationProperties("app.ollama")
record OllamaProperties(String baseUrl, String apiKey) {}
```
`OllamaProperties` is registered unconditionally — it is a plain value holder with no side effects.
`@ConditionalOnExpression` belongs **only** on `RestClientOllamaClient` (the bean that creates a live network client).
**Deliberate divergence from the OCR pattern:** the OCR service uses `@Value`-with-default because OCR is always-on and `http://ocr-service:8000` is a safe default. Ollama is truly optional — a missing URL means "feature disabled", not "use this default server". There is no safe default Ollama URL.
### 5. Optional<OllamaClient> injection
The NL search service uses constructor injection with `Optional<OllamaClient>`:
```java
private final Optional<OllamaClient> ollamaClient;
```
When empty (bean not registered), the service method returns 503 immediately:
```java
var client = ollamaClient.orElseThrow(
() -> DomainException.internal(ErrorCode.NL_SEARCH_UNAVAILABLE, "Ollama not configured"));
```
Prefer this over `@Autowired(required = false)` with a null check — the null-check pattern is noisy when the service already uses `@RequiredArgsConstructor`.
### 6. Empty API key guard
`RestClientOllamaClient` omits the `Authorization` header entirely when `apiKey` is blank:
```java
if (!apiKey.isBlank()) {
request.header("Authorization", "Bearer " + apiKey);
}
```
Sending `Authorization: Bearer ` (empty token) has undefined or potentially broken behavior depending on the Ollama version. This mirrors the `trainingToken` guard in `RestClientOcrClient.java:107`.
### 7. OLLAMA_API_KEY behavior in Ollama 0.6.5 and 0.30.6
**Empirically verified (2026-06-06) on both `0.6.5` and `0.30.6`:** `OLLAMA_API_KEY` does **not** enforce request authentication in either version.
Test matrix run against `/api/tags`:
| Configuration | No auth header | `Authorization: Bearer ` (empty) | `Authorization: Bearer wrongkey` | `Authorization: Bearer correctkey` |
|---|---|---|---|---|
| `OLLAMA_API_KEY=` (empty) | 200 | 200 | — | — |
| `OLLAMA_API_KEY` unset | 200 | — | — | — |
| `OLLAMA_API_KEY=testkey99` | 200 | 200 | 200 | 200 |
**Finding:** The `OLLAMA_API_KEY` environment variable is not listed in Ollama's startup config dump and does not gate any HTTP request in either tested version. All configurations — empty string, fully unset, and a real key — accept all requests without authentication.
**Practical implication:** `OLLAMA_API_KEY` provides no defense-in-depth in the tested versions. `archiv-net` network isolation is the only effective security control. The env var is retained in the Compose definition and `.env.example` for forward compatibility if Ollama enables enforcement in a future version, but operators must not rely on it for access control.
**Backend guard still valid:** the `RestClientOllamaClient` code-level guard (omit `Authorization` header when `apiKey.isBlank()`) remains correct behavior regardless — it prevents a malformed `Authorization: Bearer ` header from being sent.
### 8. read_only: true feasibility
**Empirically verified (2026-06-06) on both `0.6.5` and `0.30.6`:** `read_only: true` works with Ollama. All three operations — `ollama serve`, `ollama pull qwen2.5:7b-instruct-q4_K_M`, and `ollama list` — succeeded with exit code 0 in both versions.
Test run:
```bash
docker run --rm --read-only \
-v ollama_models:/root/.ollama \
--tmpfs /tmp \
--entrypoint sh ollama/ollama:0.30.6 \
-c "ollama serve & sleep 5 && ollama pull qwen2.5:7b-instruct-q4_K_M && ollama list"
```
**Note:** the entrypoint must be overridden to `sh` for the test command — the container's default entrypoint is `/bin/ollama` and does not accept `sh` as a subcommand. This is a Docker invocation detail; the Compose service definition uses the image's default entrypoint and `command:` override for the init container, which works correctly.
**Result:** `read_only: true` and `tmpfs: - /tmp:size=512m` are applied to both `ollama` and `ollama-model-init`. The `ollama_models` volume handles all persistent writes; no other paths require write access during normal operation.
### 9. Peak RSS of init container during pull
**Empirically verified (2026-06-06):** Peak RSS during `qwen2.5:7b-instruct-q4_K_M` pull was **~108 MiB**.
`docker stats` samples during the pull (15-second intervals):
| Sample | MEM |
|---|---|
| 1 | 54.89 MiB |
| 2 | 66.3 MiB |
| 5 | 97.25 MiB |
| 9 | **107.8 MiB** (peak) |
`mem_limit: 2g` is adequate — the model weights stream directly to the named volume; RSS is dominated by the Ollama server process alone (~100 MB), not the model data. No bump to 4 GB needed.
### 10. Init container pull mechanism
The `ollama-model-init` container uses a curl-based readiness loop with captured PID:
```sh
ollama serve & SERVE_PID=$!
until curl -sf http://localhost:11434/api/tags; do sleep 1; done
ollama pull qwen2.5:7b-instruct-q4_K_M
kill $SERVE_PID
```
`kill %1` (job-control syntax) is unreliable in non-interactive `sh -c` contexts. Capturing the PID via `SERVE_PID=$!` is reliable.
The same endpoint (`/api/tags`) is used for both the init container readiness loop and the main service `healthcheck`.
### 11. start_period: 60s rationale
The model is pre-pulled by `ollama-model-init` before the main service starts (via `condition: service_completed_successfully`). At main service startup, Ollama only loads model weights from the named volume and binds port 11434.
60 seconds is appropriate for this cold-start profile. 300 seconds was considered — that would be appropriate if the service pulled the model itself — but overstates actual startup time when the model is already present on the volume.
### 12. Security threat model
**Primary control:** `archiv-net` network isolation. Ollama has no externally exposed port (`expose:` only, not `ports:`). The Caddyfile must not route any path to the Ollama service.
**Note on `OLLAMA_API_KEY`:** Per §7, `OLLAMA_API_KEY` is not enforced in Ollama 0.6.5 or 0.30.6 and provides no authentication barrier against a compromised backend container. `archiv-net` network isolation is the sole effective security control. The env var is retained for forward compatibility only — do not rely on it for access control.
Both `ollama` and `ollama-model-init` receive the ADR-019 hardening baseline:
```yaml
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
```
### 13. CI exclusion strategy
Docker Compose profiles are not used — they would add developer friction (requiring `--profile ...` for all local dev commands).
CI uses explicit service selection in `docker-compose.ci.yml`:
```bash
docker compose -f docker-compose.ci.yml up -d db minio create-buckets
```
Ollama is simply not listed and is never started in CI. A YAML comment on the `ollama` service block documents this:
```yaml
# Not started in CI — CI uses explicit service selection
# (docker-compose.ci.yml: db minio create-buckets)
```
### 14. ollama_models volume operational note
The `ollama_models` named volume holds model weights only — fully reproducible by re-pull. No backup is needed.
If the volume fills after a model upgrade:
```bash
docker volume rm ollama_models && docker compose up -d
```
The init container re-pulls the model on next startup.
---
## Consequences
### Positive
- NL search runs entirely on-premises; no data leaves the server and no per-token cloud cost.
- Graceful degradation is a first-class concern: smaller or budget-constrained instances can run the app without Ollama with a single env var change.
- The init container pattern keeps model pull out of the critical startup path for the main service, giving accurate healthcheck timings.
- `@ConditionalOnExpression` with a blank-check is more correct than `@ConditionalOnProperty` for optional features with no safe default URL.
### Risks and operational implications
- **Memory pressure:** OCR + Ollama together consume ~14 GB on a 16 GB host. Running the observability stack simultaneously risks OOM kills. Monitor with `docker stats`.
- **CPU inference latency:** `qwen2.5:7b-instruct-q4_K_M` is chosen for CPU viability, but inference on 8 vCPUs will be noticeably slower than GPU-accelerated alternatives. This is acceptable for the family archive use case (low concurrency, not real-time).
- All three empirical TBD items from the original issue spec were resolved — see §7 (OLLAMA_API_KEY not enforced), §8 (`read_only: true` works), §9 (peak RSS ~108 MiB).
- Model upgrades require a `docker volume rm` to free old weights before pulling the replacement. Document this in runbook/DEPLOYMENT.md.

View File

@@ -1,4 +1,4 @@
# ADR-032 — Tag-name resolution tolerates case-collisions: exact-case first, then a deterministic lowest-id fallback, and never a `unique(lower(name))` constraint
# ADR-033 — Tag-name resolution tolerates case-collisions: exact-case first, then a deterministic lowest-id fallback, and never a `unique(lower(name))` constraint
**Date:** 2026-06-06
**Status:** Accepted
@@ -82,15 +82,58 @@ added later.
`IncorrectResultSizeDataAccessException`, and `GlobalExceptionHandler`'s generic handler maps
any stray one to `INTERNAL_ERROR` with no Hibernate/SQL leak — so no dedicated handler was
added.
- **The sibling Person path is unfixed but tracked.** `PersonService.findOrCreateByAlias`
(`findByAliasIgnoreCase`) and `findByFirstNameIgnoreCaseAndLastNameIgnoreCase` carry the same
latent `Optional`-non-unique throw on user-influenced names; deferred to #731 rather than
widened into this fix.
- **The sibling Person path is fixed the same way — see the Person extension below (#731).**
- Postgres `LOWER()` folding of umlauts (`ü`/`ä`) is the actual correctness hinge of the
fallback and cannot be proven by a mocked repo, so it is pinned by a Testcontainers
`postgres:16-alpine` test on a `Glückwünsche`/`glückwünsche` pair; a plain-ASCII test would
stay green while the bug reappeared for umlaut tags.
## Person extension (#731)
The Person domain carried the same latent throw on **two** user-influenced lookup surfaces, and
is fixed with the same exact-case-first, non-throwing pattern — but with a deliberately
**different fallback per surface**, because the two paths have different consequences.
- **Alias path — `PersonService.findOrCreateByAlias` — deterministic lowest-id (mirrors tag).**
`findByAliasIgnoreCase` (`Optional`) is replaced by `findByAlias` (exact) → `findAllByAliasIgnoreCase`
(plural, lowest id) → the existing create-when-absent branch (INSTITUTION/GROUP and the
maiden-name alias are preserved verbatim). There is no human in the importer loop and the path
creates-on-absent anyway, so a deterministic guess is the right behaviour — exactly like tags.
- **Name/sender path — `PersonService.findByName` — bail to null on ambiguity (the new wrinkle).**
Used only by `DocumentService.storeDocument` to resolve the upload **sender** from the parsed
filename. `findByFirstNameIgnoreCaseAndLastNameIgnoreCase` (`Optional`) is replaced by
`findByFirstNameAndLastName` (exact) → `findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase`
(plural). Resolution returns the exact-case match, else the single case-insensitive match, else
— on **two or more** matches — **empty**. The sender is left unset rather than guessing.
**Why this diverges from the alias (and tag) decision:** the archive's value is correct
provenance. A confidently-wrong pre-filled `Hans Müller` is worse than an empty field, because a
senior reviewer will not re-check a value that is already filled in, whereas an empty sender
routes the document into the "needs completion" state (`metadataComplete=false`) for a human to
assign. The load-bearing comment at `findByName` records this so a future "consistency cleanup"
does not reintroduce the confidently-wrong-sender bug by switching it to lowest-id.
- **Fail-closed on a null first name.** A parsed filename can lack a first name. The two new name
methods use explicit HQL equality (`= :firstName`) rather than a derived
`…IgnoreCase` query, because Spring Data folds a null derived-query argument to `first_name IS
NULL` — which would silently widen the match and pull a last-name-only / institution row in as a
"sender" (a quiet provenance-integrity defect). With HQL equality a null binds as `= NULL`,
which never matches, so a null first name resolves to **no sender**. This is pinned by a
real-Postgres repository test.
- **Scope — "ambiguous" is case-insensitive only.** Both exact-case lookups (`findByAlias`,
`findByFirstNameAndLastName`) return `Optional`, so two **byte-identical same-case** rows would
still throw `NonUniqueResultException`. That is a true data anomaly, deliberately out of scope
(it is not a case-collision), and it surfaces as the opaque `INTERNAL_ERROR` — never a silently
wrong row — so it is no worse than any other unexpected error and needs no extra handling here.
- **Same stance as tags otherwise:** no `unique(lower(alias))` / `unique(lower(name))` constraint
(collisions are valid human labels; `source_ref` is the stable identity per ADR-025), no
merge/dedupe, code-only and reversible, and no shared `resolveExactThenCi(...)` helper — the
two Person paths have different fallbacks, so the exact→CI→fallback logic is inlined at each
with its load-bearing comment (KISS).
## Alternatives considered
- **A `unique(lower(name))` index** — rejected: the collisions are valid canonical nodes, and

View File

@@ -0,0 +1,53 @@
# ADR-034 — Remove NL/smart-search (supersedes ADR-028 ×2, ADR-034-ollama, ADR-035)
**Date:** 2026-06-07
**Status:** Accepted
**Issue:** #772
**Supersedes:** ADR-028 (nl-search-ollama), ADR-028 (ollama-docker-compose-service), ADR-034 (ollama-production-deployment-and-keep-alive), ADR-035 (rule-based-nlp-service)
---
## Context
The natural-language search feature ("KI-Suche" / smart search) allowed users to enter
free-form queries like *"Was hat Walter an Emma im Krieg geschrieben?"* and have them
interpreted by an LLM into structured filters (persons, tags, date range, keywords).
The feature went through two major iterations:
1. **Ollama integration** (ADR-028): an `ollama` Docker service running a local LLM
(llama3.2/gemma3) parsed queries via a JSON-mode prompt.
2. **Rule-based NLP service** (ADR-035): after Ollama proved too slow and unreliable on
CPU-only hardware, a Python FastAPI microservice (`nlp-service`, port 8001) replaced
it with deterministic regex + spaCy parsing plus a lightweight LLM call.
Both approaches shared the same fundamental problem: inference on the production server
(Hetzner Serverbörse, no GPU, 64 GB RAM, i7-6700) was too slow to be useful, with
typical query latencies of 1030 seconds. Users got better and faster results from
the existing keyword search with date/person/tag filters.
## Decision
**Remove the NL search feature entirely.** The Python `nlp-service` microservice, the
Spring Boot `search/` package (`NlSearchController`, `NlQueryParserService`,
`RestClientNlpClient`, `NlSearchRateLimiter`, and all supporting classes), the frontend
NL search components (`SmartModeToggle`, `SmartSearchStatus`, `InterpretationChipRow`,
`DisambiguationPicker`), the related Docker Compose services, Prometheus scrape job,
Grafana dashboard, and all i18n keys are removed.
The existing structured search (FTS keyword + person/tag/date/directional filters) is
sufficient for the archive's current audience and search workload.
## Consequences
- **Capability removed:** users can no longer enter free-form natural-language queries.
They must use the structured filter bar (keyword text box + person/tag/date/directional
dropdowns). For documents where these filters are sufficient, there is no regression.
- **Operational simplification:** the Docker Compose stack loses two services
(`nlp-service` and previously `ollama`/`ollama-model-init`). Memory budget on the
production host is freed. No external model weights need to be kept warm.
- **Future reinstatement:** if a GPU-capable host becomes available, re-implementing
server-side LLM inference would be straightforward given the clean separation of the
`NlSearchController` entry point. However, this ADR deliberately avoids leaving dead
infrastructure or stub code in place — start clean if and when that becomes viable.
- **No data or schema change:** only query/endpoint code and Docker services are removed.
The `documents`, `persons`, and `tags` tables and their FTS indexes are untouched.

View File

@@ -9,10 +9,12 @@ Person(member, "Family Member", "Access by administrator invite. Searches, brows
System(familienarchiv, "Familienarchiv", "Web application for digitising, organising, and searching family documents")
System_Ext(mail, "Email Service", "SMTP server. Delivers notification emails (mentions, replies) and password-reset links.")
System_Ext(glitchtip, "GlitchTip", "Self-hosted error tracking (Sentry-compatible). Receives frontend and backend error events with stack traces.")
System_Ext(ollama, "Ollama (self-hosted)", "Local LLM inference server (qwen2.5:7b). Parses natural-language search queries into structured filters. Runs in the same Docker Compose stack.")
Rel(admin, familienarchiv, "Manages via browser", "HTTPS")
Rel(member, familienarchiv, "Searches, reads, and transcribes via browser", "HTTPS")
Rel(familienarchiv, mail, "Sends notification and password-reset emails (optional)", "SMTP")
Rel(familienarchiv, glitchtip, "Sends error events with errorId and stack trace", "HTTPS")
Rel(familienarchiv, ollama, "NL query parsing for natural-language search", "HTTP / REST (internal)")
@enduml

View File

@@ -12,15 +12,13 @@ System_Boundary(archiv, "Familienarchiv (Docker Compose)") {
Container(frontend, "Web Frontend", "SvelteKit / Node adapter / port 3000", "Server-side rendered UI. Handles auth session cookies, document search and viewer, transcription editor, annotation layer, family tree (Stammbaum), stories (Geschichten), activity feed (Chronik), enrichment workflow, and admin panel.")
Container(backend, "API Backend", "Spring Boot 4 / Java 21 / Jetty / port 8080", "REST API. Implements document management, search, user auth, file upload/download, transcription, OCR orchestration, and SSE notifications. Trusts X-Forwarded-* headers from Caddy.")
Container(ocr, "OCR Service", "Python FastAPI / port 8000", "Handwritten text recognition (HTR) and OCR microservice. Single-node by design — see ADR-001. Reachable only on the internal Docker network; no external port exposed.")
Container(ollama, "Ollama LLM Service", "ollama/ollama:0.30.6 / port 11434 (internal only)", "Local LLM inference server for NL search. Runs qwen2.5:7b-instruct-q4_K_M on CPU. Reachable only on the internal Docker network; no external port exposed. Disabled when APP_OLLAMA_BASE_URL is unset or blank.")
' Named volume: ollama_models — model weights, fully reproducible, no backup needed
ContainerDb(db, "Relational Database", "PostgreSQL 16", "Stores document metadata, persons, users, permission groups, tags, transcription blocks, audit log, and Spring Session data.")
ContainerDb(storage, "Object Storage", "MinIO (S3-compatible)", "Stores the actual document files (PDFs, scans). Backend uses a bucket-scoped service account (archiv-app), not MinIO root.")
Container(mc, "Bucket / Service-Account Init", "MinIO Client (mc)", "One-shot container on startup. Idempotent: creates the archive bucket, the archiv-app service account, and attaches the readwrite policy.")
}
System_Boundary(observability, "Observability Stack (/opt/familienarchiv/docker-compose.observability.yml)") {
Container(prometheus, "Prometheus", "prom/prometheus:v3.4.0", "Scrapes metrics from backend (8081 /actuator/prometheus), OCR service (8000 /metrics), Ollama (11434 /metrics), node-exporter, and cAdvisor. Retention: 30 days.")
Container(prometheus, "Prometheus", "prom/prometheus:v3.4.0", "Scrapes metrics from backend (8081 /actuator/prometheus), OCR service (8000 /metrics), node-exporter, and cAdvisor. Retention: 30 days.")
Container(node_exporter, "Node Exporter", "prom/node-exporter:v1.9.0", "Host-level CPU, memory, disk, and network metrics.")
Container(cadvisor, "cAdvisor", "gcr.io/cadvisor/cadvisor:v0.52.1", "Per-container resource metrics.")
Container(loki, "Loki", "grafana/loki:3.4.2", "Stores log streams from all containers.")
@@ -47,8 +45,7 @@ Rel(promtail, loki, "Pushes log streams", "HTTP/Loki push API")
Rel(backend, tempo, "Sends distributed traces via OTLP", "HTTP / OTLP / port 4318 (archiv-net)")
Rel(prometheus, backend, "Scrapes JVM + HTTP metrics", "HTTP 8081 /actuator/prometheus")
Rel(prometheus, ocr, "Scrapes OCR + http_* metrics", "HTTP 8000 /metrics")
Rel(backend, ollama, "NL search inference requests", "HTTP / REST / JSON")
Rel(prometheus, ollama, "Scrapes LLM request metrics", "HTTP 11434 /metrics")
Rel(grafana, prometheus, "Queries metrics", "HTTP 9090")
Rel(grafana, loki, "Queries logs", "HTTP 3100")
Rel(grafana, tempo, "Queries traces", "HTTP 3200")

View File

@@ -10,6 +10,11 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
Component(homePage, "/ (Home / Search)", "SvelteKit Route", "Loader: parses URL params (q, from, to, senderId, receiverId, tags), fetches /api/documents/search and /api/persons. Renders search form with full-text, date range, sender/receiver typeahead, and tag filters.")
Component(docsListPageTs, "/documents/+page.ts", "SvelteKit Client Loader", "Client-side load gated by matchMedia('(min-width: 1024px)') and ?view query. Fetches /api/documents/density only on desktop (Tailwind lg breakpoint) and outside calendar view; degrades to empty buckets on network failure.")
Component(timelineFilter, "TimelineDensityFilter.svelte", "Svelte Component", "Per-month density bars above the document list. Click selects a single month, emits onchange({from, to}) using YYYY-MM-DD boundaries. Hidden on mobile and tablet (below lg, 1024px) and in calendar view.")
Component(searchFilterBar, "SearchFilterBar.svelte", "Svelte Component", "Search/filter card on /documents. Hosts the keyword input, sort, advanced filters, and the smart-mode toggle. In smart mode submits the NL query on Enter via onSmartSearch instead of the live keyword search.")
Component(smartToggle, "search/SmartModeToggle.svelte", "Svelte Component", "Toggle pill (KI/Text) inside the search input. aria-pressed; switches between keyword and NL (smart) search modes.")
Component(chipRow, "search/InterpretationChipRow.svelte", "Svelte Component", "Renders NL interpretation chips (Absender / directional / Zeitraum / Stichwort). Removing a chip emits onRemoveChip; the page re-runs a keyword GET with the remaining params.")
Component(smartStatus, "search/SmartSearchStatus.svelte", "Svelte Component", "Full-area panels for NL search: loading (role=status), 503 SMART_SEARCH_UNAVAILABLE (with keyword fallback), 429 SMART_SEARCH_RATE_LIMITED.")
Component(disambig, "search/DisambiguationPicker.svelte", "Svelte Component", "Accessible single-select disclosure for ambiguous person names; selecting a candidate re-runs the search via GET.")
Component(docDetail, "/documents/[id]", "SvelteKit Route", "Loader: GET /api/documents/{id}. Page: metadata panel, inline file viewer, transcription editor, annotation layer, and comment thread.")
Component(docEdit, "/documents/[id]/edit", "SvelteKit Route", "Edit form with PersonTypeahead, TagInput, date/location fields. Form action: PUT /api/documents/{id}.")
Component(docNew, "/documents/new", "SvelteKit Route", "Upload form for a new document. Loader: GET /api/persons. Form action: POST /api/documents with multipart file.")
@@ -25,6 +30,12 @@ Rel(user, homePage, "Searches and browses", "HTTPS / Browser")
Rel(homePage, backend, "GET /api/documents/search, GET /api/persons", "HTTP / JSON")
Rel(docsListPageTs, backend, "GET /api/documents/density (desktop only, ≥1024px)", "HTTP / JSON")
Rel(homePage, timelineFilter, "Mounts above the result list")
Rel(homePage, searchFilterBar, "Mounts the search/filter card")
Rel(searchFilterBar, smartToggle, "Embeds the smart-mode toggle in the input")
Rel(homePage, backend, "POST /api/search/nl (smart mode)", "HTTP / JSON")
Rel(homePage, smartStatus, "Renders loading / 503 / 429 panels")
Rel(homePage, chipRow, "Renders interpretation chips; handles chip removal")
Rel(homePage, disambig, "Renders the picker when names are ambiguous")
Rel(docsListPageTs, timelineFilter, "Provides density / minDate / maxDate props")
Rel(docDetail, backend, "GET /api/documents/{id}, GET /api/documents/{id}/file", "HTTP / JSON + Binary")
Rel(docEdit, backend, "PUT /api/documents/{id}", "HTTP / Multipart")

View File

@@ -20,24 +20,19 @@ The observability stack (Prometheus, Loki, Grafana, Tempo, GlitchTip) ships as a
---
## VPS Sizing Recommendations
## Server Sizing
### Recommended: Hetzner CX32
### Current Production Server: Hetzner Dedicated (Serverbörse)
**Specs**: 4 vCPU, 8 GB RAM, 80 GB SSD · **Cost**: 17 EUR/mo
**Specs**: Intel Core i7-6700 (4C/8T, 3.4 GHz), 64 GB RAM · acquired via Hetzner server auction
Sufficient for the application stack (Postgres, MinIO, OCR with `mem_limit: 12g`, backend, frontend, Caddy) on a CX32 today. Once the observability stack lands (Prometheus/Loki/Grafana/Alertmanager add ~2 GB) consider a CX42.
Comfortably handles the full application stack (Postgres, MinIO, OCR with `mem_limit: 12g`, backend, frontend, Caddy, full observability stack) with headroom to spare. The 64 GB RAM means OCR, Ollama inference, and the observability stack can all run concurrently without memory pressure.
### When to Upgrade: Hetzner CX42
### When to Reconsider Hardware
**Specs**: 8 vCPU, 16 GB RAM · **Cost**: 29 EUR/mo
Upgrade when:
- Observability stack adds memory pressure (Loki + Grafana with >30 days retention)
- OCR throughput needs scaling beyond a single-node Surya/Kraken setup
- Real user load profiled in Grafana shows response-time degradation
Never upgrade the VPS tier before profiling — most perceived performance issues are application bugs, not resource constraints.
- CPU is Skylake (2015) — single-threaded performance is the likely bottleneck before RAM
- Profile with Grafana dashboards before concluding hardware is the constraint
- Most perceived performance issues are application bugs (unindexed queries, N+1 loads), not resource limits
---
@@ -45,12 +40,11 @@ Never upgrade the VPS tier before profiling — most perceived performance issue
| Service | Cost |
|---|---|
| Hetzner CX32 VPS | 17.00 EUR |
| Hetzner dedicated server (Serverbörse, i7-6700, 64 GB RAM) | see invoice |
| Hetzner DNS | 0.00 EUR |
| Hetzner SMTP relay | ~1.00 EUR |
| **Total** | **~18 EUR/mo** |
MinIO data lives on the VPS disk (no Object Storage line item yet). The Hetzner OBS migration would add ~5 EUR/mo at ~200 GB.
MinIO data lives on the server disk (no Object Storage line item yet). The Hetzner OBS migration would add ~5 EUR/mo at ~200 GB.
Equivalent SaaS stack: 200300 EUR/mo.

View File

@@ -0,0 +1,808 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Lesereisen — Journey-Editor · Familienarchiv</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<style>
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--navy:#012851;--mint:#A1DCD8;--sand:#F0EFE9;--turquoise:#00C7B1;--blue-tint:#E6F1FB;--blue:#2D7DD2;--blue-dark:#185FA5;--green-tint:#E8F5EA;--green:#3D8C4A;--green-dark:#2E6E39;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.doc-header p{font-size:13px;color:var(--color-text-muted);max-width:680px;}
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;}
.pill-o{background:var(--orange-tint);color:var(--orange-dark);}
.section{margin-bottom:64px;}
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
.jh{padding:20px 24px;border-radius:var(--radius-xl);margin-bottom:40px;display:flex;align-items:center;gap:16px;}
.jh .jn{font-family:var(--font-display);font-size:48px;font-weight:300;line-height:1;opacity:.5;}
.jh h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.jh p{font-size:13px;line-height:1.5;}.jh .fl{font-family:var(--font-mono);font-size:11px;margin-top:6px;opacity:.7;}
.jh-o{background:var(--orange-tint);border:1px solid #F0C99A;}
.jh-o .jn{color:var(--orange);}
.jh-o p,.jh-o .fl{color:var(--orange-dark);}
.scr{margin-bottom:56px;}
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;flex-direction:column;}
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
.fa-nav{height:32px;background:var(--navy);display:flex;align-items:center;padding:0 12px;gap:8px;flex-shrink:0;}
.fa-logo{font-size:7px;font-weight:900;color:#fff;letter-spacing:.8px;border-bottom:2px solid var(--mint);padding-bottom:1px;}
.fa-link{font-size:5.5px;color:rgba(255,255,255,.4);font-weight:700;text-transform:uppercase;letter-spacing:.05em;}
.fa-link.active{color:var(--mint);}
.fa-nav-r{margin-left:auto;display:flex;gap:5px;align-items:center;}
.fa-av{width:16px;height:16px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5);}
.m-nav{height:26px;background:var(--navy);display:flex;align-items:center;padding:0 10px;gap:6px;flex-shrink:0;}
.m-logo{font-size:6px;font-weight:900;color:#fff;letter-spacing:.7px;border-bottom:1.5px solid var(--mint);padding-bottom:1px;}
.m-nav-r{margin-left:auto;display:flex;gap:4px;align-items:center;}
.m-av{width:14px;height:14px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:rgba(255,255,255,.5);}
.m-ham{display:flex;flex-direction:column;gap:2px;width:12px;}
.m-ham span{height:1.5px;background:rgba(255,255,255,.6);border-radius:1px;}
/* ── impl-ref table ── */
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
.at thead tr{border-bottom:1px solid #2A2A26;}
.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}
.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}
.at tr:last-child td{border-bottom:none;}
.at td:first-child{color:#7A7A72;}
.at td:nth-child(2){color:#E8E8E2;font-weight:500;}
.at td:nth-child(3){color:#5A5A55;}
.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
/* ── LLM guide ── */
.llm{background:var(--color-page);border:2px solid var(--navy);border-radius:var(--radius-xl);padding:32px 40px;margin-top:64px;}
.llm h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;color:var(--navy);}
.llm h3{font-size:14px;font-weight:600;margin:20px 0 8px;}
.llm h4{font-size:12px;font-weight:600;margin:14px 0 6px;color:var(--color-text-muted);}
.llm p,.llm li{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
.llm li{margin-bottom:4px;}
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:1px 5px;border-radius:3px;}
.llm table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}
.llm th,.llm td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--color-border);}
.llm th{font-weight:500;color:var(--color-text);font-size:11px;text-transform:uppercase;letter-spacing:.05em;}
.llm td{color:var(--color-text-muted);}
/* ── Editor chrome (shared with writer spec) ── */
.ed-topbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:0 14px;gap:8px;height:38px;flex-shrink:0;}
.ed-back{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--color-text-muted);flex-shrink:0;}
.ed-title-label{font-family:var(--font-sans);font-size:10px;font-weight:500;color:var(--color-text);flex:1;}
.ed-status-pill{display:inline-flex;align-items:center;padding:2px 7px;border-radius:20px;font-size:8px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;flex-shrink:0;}
.ed-status-draft{background:#F0EFE9;color:#6B6A63;border:1px solid #D8D7D0;}
.ed-status-pub{background:var(--green-tint);color:var(--green-dark);border:1px solid #A0D8A8;}
.ed-delete-link{font-size:8px;font-weight:600;color:#DC4C3E;margin-left:8px;}
.ed-split{display:flex;flex:1;overflow:hidden;}
.ed-sidebar{width:210px;flex-shrink:0;border-left:1px solid #e4e2d7;background:#fff;display:flex;flex-direction:column;overflow-y:auto;}
.ed-sb-section{padding:12px 12px 10px;}
.ed-sb-title{font-size:8px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:8px;}
.ed-sb-divider{height:1px;background:#e4e2d7;}
.ed-search-row{display:flex;align-items:center;gap:6px;background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-sm);padding:4px 8px;margin-bottom:6px;}
.ed-search-input{font-size:9px;color:var(--color-text-muted);}
.ed-chip{display:inline-flex;align-items:center;gap:4px;padding:3px 7px;background:var(--sand);border:1px solid var(--color-border);border-radius:12px;font-size:8px;font-weight:500;color:var(--color-text);margin:0 4px 4px 0;}
.ed-chip-x{color:var(--color-text-muted);font-size:9px;cursor:pointer;margin-left:2px;}
.ed-hint{font-size:8px;color:var(--color-text-muted);line-height:1.5;margin-top:4px;}
.ed-savebar{background:#fff;border-top:1px solid #e4e2d7;padding:9px 14px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0;gap:10px;}
.ed-savebar-hint{font-size:8px;color:var(--color-text-muted);}
.ed-savebar-actions{display:flex;align-items:center;gap:7px;}
.ed-btn-ghost{font-size:9px;font-weight:600;padding:5px 12px;border-radius:var(--radius-sm);border:1px solid var(--color-border);color:var(--color-text);background:#fff;cursor:pointer;white-space:nowrap;}
.ed-btn-ghost.retract{color:#B46820;border-color:#E8D5B0;}
.ed-btn-primary{font-size:9px;font-weight:600;padding:5px 12px;border-radius:var(--radius-sm);background:var(--navy);color:#fff;border:none;cursor:pointer;white-space:nowrap;}
/* ── Journey Editor main area ── */
.je-main{flex:1;display:flex;flex-direction:column;padding:14px 16px;overflow-y:auto;gap:8px;background:var(--color-page);}
.je-title-input{font-family:var(--font-display);font-size:15px;font-weight:400;color:var(--color-text);border:none;border-bottom:1px solid var(--color-border);padding:4px 0 6px;width:100%;outline:none;background:transparent;letter-spacing:-.01em;}
.je-title-input.placeholder{color:var(--color-text-muted);font-style:italic;}
.je-sep{height:1px;background:var(--color-border);margin:2px 0;}
.je-intro-area{font-family:Georgia,serif;font-size:9px;line-height:1.7;color:var(--color-text-muted);font-style:italic;border:none;padding:5px 0;width:100%;outline:none;background:transparent;min-height:36px;resize:none;}
.je-intro-label{font-size:7.5px;font-weight:600;letter-spacing:.07em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:2px;}
.je-list-label{font-size:7.5px;font-weight:600;letter-spacing:.07em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:5px;margin-top:4px;}
/* ── Item rows ── */
.je-item{display:flex;align-items:stretch;gap:0;background:#fff;border:1px solid #E4E2D7;border-radius:4px;margin-bottom:5px;overflow:hidden;}
.je-drag{width:16px;background:#F5F4EE;border-right:1px solid #E4E2D7;display:flex;align-items:center;justify-content:center;cursor:grab;flex-shrink:0;}
.je-drag-dots{display:flex;flex-direction:column;gap:2px;}
.je-drag-dot{width:3px;height:3px;border-radius:50%;background:#C4C3BC;}
.je-num{width:20px;display:flex;align-items:flex-start;justify-content:center;padding-top:8px;font-size:8px;font-weight:700;color:#9B9A93;flex-shrink:0;}
.je-body{flex:1;padding:7px 8px 7px 4px;}
.je-doc-title{font-size:9px;font-weight:600;color:var(--navy);line-height:1.3;margin-bottom:2px;}
.je-doc-meta{font-size:7.5px;color:var(--color-text-muted);margin-bottom:5px;}
.je-note-area{width:100%;min-height:32px;font-family:Georgia,serif;font-size:8px;line-height:1.55;color:var(--color-text);font-style:italic;border:1px solid var(--color-border);border-radius:3px;background:var(--color-surface);padding:4px 6px;resize:none;outline:none;}
.je-note-add{font-size:7.5px;font-weight:600;color:var(--blue);cursor:pointer;display:inline-flex;align-items:center;gap:2px;}
.je-remove{width:24px;display:flex;align-items:flex-start;justify-content:center;padding-top:7px;flex-shrink:0;}
.je-remove-x{font-size:11px;color:#C4C3BC;cursor:pointer;line-height:1;font-weight:300;}
.je-interlude-bg{background:var(--orange-tint);border-color:#F0C99A;}
.je-interlude-icon{font-size:8px;color:var(--orange);margin-bottom:2px;}
.je-interlude-area{width:100%;min-height:36px;font-family:Georgia,serif;font-size:8px;line-height:1.6;color:var(--color-text);font-style:italic;border:1px solid #F0C99A;border-radius:3px;background:rgba(255,255,255,.6);padding:4px 6px;resize:none;outline:none;}
.je-empty{padding:16px;text-align:center;border:1px dashed var(--color-border);border-radius:4px;background:var(--color-surface);}
.je-empty-text{font-family:Georgia,serif;font-size:8px;color:var(--color-text-muted);font-style:italic;}
/* ── Add bar ── */
.je-add-bar{display:flex;gap:7px;padding:6px 0 4px;}
.je-add-btn{font-size:8px;font-weight:600;padding:5px 10px;border-radius:3px;border:1px dashed var(--color-border);color:var(--color-text-muted);background:transparent;cursor:pointer;display:flex;align-items:center;gap:3px;}
.je-add-btn:hover{border-color:var(--navy);color:var(--navy);}
/* ── Inline note editing state (highlight) ── */
.je-note-editing{border-color:var(--navy);background:#fff;}
/* ── Mobile journey editor ── */
.mob-topbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:0 10px;gap:6px;height:34px;flex-shrink:0;}
.mob-back{font-size:8px;color:var(--color-text-muted);}
.mob-label{font-family:var(--font-sans);font-size:9px;font-weight:500;color:var(--color-text);flex:1;}
.mob-body{flex:1;overflow-y:auto;padding:10px 12px;display:flex;flex-direction:column;gap:7px;background:var(--color-page);}
.mob-title-input{font-family:var(--font-display);font-size:13px;color:var(--color-text-muted);font-style:italic;border:none;border-bottom:1px solid var(--color-border);padding:3px 0 5px;width:100%;background:transparent;outline:none;}
.mob-collapsible{background:#fff;border:1px solid #e4e2d7;border-radius:3px;overflow:hidden;}
.mob-coll-hdr{display:flex;align-items:center;justify-content:space-between;padding:7px 9px;font-size:8.5px;font-weight:600;color:var(--color-text);}
.mob-coll-chevron{font-size:9px;color:var(--color-text-muted);}
.mob-savebar{background:#fff;border-top:1px solid #e4e2d7;padding:8px 10px;display:flex;gap:6px;flex-shrink:0;}
.mob-btn{font-size:8.5px;font-weight:600;padding:7px 0;border-radius:3px;text-align:center;flex:1;}
.mob-btn-ghost{border:1px solid var(--color-border);color:var(--color-text);background:#fff;}
.mob-btn-primary{background:var(--navy);color:#fff;border:none;}
.mob-je-item{display:flex;align-items:stretch;gap:0;background:#fff;border:1px solid #E4E2D7;border-radius:3px;margin-bottom:4px;overflow:hidden;}
.mob-je-drag{width:14px;background:#F5F4EE;border-right:1px solid #E4E2D7;display:flex;align-items:center;justify-content:center;}
.mob-je-body{flex:1;padding:6px 7px;}
.mob-je-title{font-size:8.5px;font-weight:600;color:var(--navy);line-height:1.3;margin-bottom:1px;}
.mob-je-meta{font-size:7px;color:var(--color-text-muted);}
.mob-je-note{margin-top:4px;padding:3px 5px;background:var(--color-surface);border-left:2px solid var(--mint);font-size:7.5px;font-style:italic;color:var(--color-text-muted);}
.mob-je-interlude{background:var(--orange-tint);border-color:#F0C99A;}
.mob-je-interlude-text{font-size:7.5px;font-style:italic;color:var(--color-text);}
@media(max-width:900px){.doc{padding:24px 16px 80px;}}
</style>
</head>
<body>
<div class="doc">
<!-- ═══ DOC HEADER ═══ -->
<div class="doc-header">
<div>
<h1>Lesereisen — Journey-Editor</h1>
<p>Kuratierungs-Oberfläche für <code>JourneyEditor</code> auf <code>/geschichten/[id]/edit</code> (wenn <code>type === 'JOURNEY'</code>). Geordnete Briefliste mit Drag-to-Reorder, Dokumenten-Picker, Interlude-Notizen und Inline-Annotation-Editing. Ersetzt den TipTap-Editor für den Journey-Typ.</p>
</div>
<div class="doc-meta">
Familienarchiv<br/>
<span class="pill pill-o">Final Spec</span><br/>
2026-06-07 &middot; @leonievoss<br/>
<span style="font-size:10px;margin-top:4px;display:inline-block;">Issue #753</span>
</div>
</div>
<!-- ═══ JOURNEY HEADER ═══ -->
<div class="jh jh-o">
<div class="jn">E</div>
<div>
<h2>Journey-Editor</h2>
<p>BLOG_WRITERs kuratieren eine geordnete Briefsequenz — Briefe hinzufügen, Zwischentexte einfügen, Reihenfolge per Drag anpassen, Notizen inline bearbeiten.</p>
<div class="fl">/geschichten/[id]/edit (type === 'JOURNEY')</div>
</div>
</div>
<!-- ═══ KONZEPT ═══ -->
<div class="section">
<div class="section-title">Konzept</div>
<p class="prose">Der <code>JourneyEditor</code> ist eine parallele Implementierung zum bestehenden <code>GeschichteEditor</code> und wird auf derselben Edit-Route eingeblendet wenn <code>type === 'JOURNEY'</code>. Das Split-Layout (70/30) bleibt erhalten: links die Briefliste, rechts die Sidebar mit Personen und Status.</p>
<p class="prose">Die linke Fläche zeigt: oben einen optionalen Einleitungs-Textarea (<code>body</code>), darunter die geordnete Itemliste, ganz unten eine Aktionsleiste mit „+ Brief hinzufügen" und „+ Zwischentext hinzufügen". Jedes Item hat einen Drag-Handle, eine Positionsnummer, den Inhalt und einen Entfernen-Button.</p>
<p class="prose">Dokument-Items zeigen Titel und Kurz-Metadaten. Eine „Notiz hinzufügen/bearbeiten"-Aktion expandiert ein Textarea direkt unterhalb des Items — kein Modal, kein separates Formular. Interlude-Items (reiner Zwischentext) zeigen direkt ein editierbares Textarea mit orangenem Hintergrund zur klaren visuellen Unterscheidung.</p>
<p class="prose">Speicheraktionen: Speichern (bei veröffentlichter Journey) oder „Entwurf speichern" + „Veröffentlichen" (bei DRAFT). Die Savebarlogik ist identisch zum GeschichteEditor. Alle Mutationen lösen sofort einen API-Call aus und aktualisieren den lokalen Zustand optimistisch — kein separates Save für einzelne Items.</p>
</div>
<!-- ═══ SCREEN LE-1: EMPTY EDITOR ═══ -->
<div class="section">
<div class="section-title">Screens — Leerer Editor</div>
<div class="scr">
<div class="scr-head">
<h3>LE-1 — Journey-Editor leer</h3>
<span class="scr-id">Issue #753 · LE-1</span>
</div>
<p class="scr-desc">Ausgangszustand einer neuen oder leeren Lesereise. Titel-Input oben. Darunter ein optionaler Einleitungs-Textarea. Leere Itemliste mit Leerstate-Text. Aktionsleiste mit zwei Buttons. Sidebar: Personen-Verknüpfung und Status-Anzeige. Keine Items → „Veröffentlichen" noch nicht aktiv (Disabled-Hint erscheint).</p>
<p class="scr-var"><strong>Varianten:</strong> Neuer Entwurf ohne Titel (hier gezeigt) · Mit Titel, leere Liste</p>
<div class="previews">
<div class="prev-col" style="width:100%;max-width:1040px;">
<span class="bp-lbl">Desktop — 1040px · Neuer Entwurf</span>
<div class="desk" style="min-height:500px;">
<div class="fa-nav">
<span class="fa-logo">ARCHIV</span>
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
<span class="fa-link">Dokumente</span>
<span class="fa-link">Personen</span>
<span class="fa-link active">Geschichten</span>
<span class="fa-link">Chronik</span>
<div class="fa-nav-r">
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">MR</div>
</div>
</div>
<div class="ed-topbar">
<div class="ed-back">&#8592;</div>
<div class="ed-title-label" style="display:flex;align-items:center;gap:6px;">
Neue Lesereise
<span style="font-size:7px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--orange-dark);background:var(--orange-tint);border:1px solid #F0C99A;padding:1px 5px;border-radius:3px;">REISE</span>
</div>
<div class="ed-status-pill ed-status-draft">ENTWURF</div>
</div>
<div class="ed-split">
<!-- Left: Journey editor area -->
<div class="je-main">
<input class="je-title-input placeholder" type="text" value="" placeholder="Titel der Lesereise" readonly/>
<div class="je-sep"></div>
<div>
<div class="je-intro-label">Einleitung (optional)</div>
<textarea class="je-intro-area" placeholder="Optionaler Einleitungstext für diese Lesereise…" readonly></textarea>
</div>
<div class="je-sep"></div>
<div class="je-list-label">Briefe &amp; Zwischentexte</div>
<div class="je-empty">
<div class="je-empty-text">Noch keine Einträge. Füge den ersten Brief oder einen Zwischentext hinzu.</div>
</div>
<div class="je-add-bar">
<button class="je-add-btn">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
Brief hinzufügen
</button>
<button class="je-add-btn">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
Zwischentext hinzufügen
</button>
</div>
</div>
<!-- Right: Sidebar -->
<div class="ed-sidebar">
<div class="ed-sb-section">
<div class="ed-sb-title">Personen</div>
<div class="ed-search-row">
<span style="font-size:9px;color:var(--color-text-muted);">&#128269;</span>
<div class="ed-search-input">Person suchen…</div>
</div>
<div class="ed-hint">Welche historischen Personen kommen in dieser Lesereise vor?</div>
</div>
<div class="ed-sb-divider"></div>
<div class="ed-sb-section">
<div class="ed-sb-title">Status</div>
<div class="ed-status-pill ed-status-draft" style="font-size:9px;">ENTWURF</div>
<div class="ed-hint" style="margin-top:6px;">Noch nicht öffentlich sichtbar. Füge mindestens einen Brief hinzu, um zu veröffentlichen.</div>
</div>
</div>
</div>
<div class="ed-savebar">
<span class="ed-savebar-hint">Alle Änderungen werden als Entwurf gespeichert.</span>
<div class="ed-savebar-actions">
<button class="ed-btn-ghost">Entwurf speichern</button>
<button class="ed-btn-primary" style="opacity:.4;cursor:not-allowed;">Veröffentlichen</button>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>impl-ref — LE-1 Leerer Editor</h4>
<table class="at">
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
<tbody>
<tr class="grp"><td colspan="3">Seitenstruktur</td></tr>
<tr><td>Bedingte Verzweigung</td><td>{#if geschichte.type === 'JOURNEY'}&lt;JourneyEditor /&gt;{:else}&lt;GeschichteEditor /&gt;{/if}</td><td>in edit/+page.svelte; Props: geschichte: Geschichte</td></tr>
<tr><td>Split-Layout</td><td>flex flex-1 overflow-hidden (gleich wie GeschichteEditor)</td><td>70/30; Sidebar identisch</td></tr>
<tr><td>Topbar-Badge</td><td>„REISE" Pill neben dem Titel-Label</td><td>orange; kein interaktives Element; zeigt Typ</td></tr>
<tr class="grp"><td colspan="3">Titel-Input</td></tr>
<tr><td>Titel-Input</td><td>font-serif text-2xl border-b border-line pb-2 w-full bg-transparent outline-none</td><td>bind:value={title}; gleiche Validierung wie GeschichteEditor (required)</td></tr>
<tr class="grp"><td colspan="3">Einleitungs-Textarea</td></tr>
<tr><td>Intro-Textarea</td><td>font-serif text-sm italic text-ink-3 leading-relaxed w-full resize-none bg-transparent outline-none border-none py-1</td><td>bind:value={body}; plaintext; auto-resize per rows-attr oder JS</td></tr>
<tr><td>Label</td><td>text-[10px] font-bold uppercase tracking-widest text-ink-3 mb-1</td><td>„EINLEITUNG (OPTIONAL)"</td></tr>
<tr class="grp"><td colspan="3">Leerstate</td></tr>
<tr><td>Leerstate-Container</td><td>py-8 text-center border border-dashed border-line rounded-sm bg-surface</td><td>verschwindet sobald erstes Item vorhanden</td></tr>
<tr><td>Leerstate-Text</td><td>font-serif text-xs text-ink-3 italic</td><td></td></tr>
<tr class="grp"><td colspan="3">Veröffentlichen-Button</td></tr>
<tr><td>Disabled-Zustand</td><td>disabled={items.length === 0 || !title.trim()}</td><td>opacity-40 + cursor-not-allowed; keine Tooltip nötig — Sidebar-Hint erklärt es</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ═══ SCREEN LE-2: EDITOR WITH ITEMS ═══ -->
<div class="section">
<div class="section-title">Screens — Editor mit Einträgen</div>
<div class="scr">
<div class="scr-head">
<h3>LE-2 — Journey-Editor mit Einträgen</h3>
<span class="scr-id">Issue #753 · LE-2</span>
</div>
<p class="scr-desc">Gefüllte Itemliste mit gemischten Typen: Dokument-Item ohne Notiz, Interlude-Item (reiner Zwischentext), Dokument-Item mit bestehender Notiz. Jedes Item zeigt Drag-Handle links, Positionsnummer, Inhalt und Entfernen-Button. Aktionsleiste bleibt unter der Liste sichtbar.</p>
<p class="scr-var"><strong>Varianten:</strong> Veröffentlichte Journey (hier gezeigt) · Entwurf · Mobile</p>
<div class="previews">
<div class="prev-col" style="width:100%;max-width:1040px;">
<span class="bp-lbl">Desktop — 1040px · VERÖFFENTLICHT</span>
<div class="desk" style="min-height:580px;">
<div class="fa-nav">
<span class="fa-logo">ARCHIV</span>
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
<span class="fa-link">Dokumente</span>
<span class="fa-link">Personen</span>
<span class="fa-link active">Geschichten</span>
<div class="fa-nav-r">
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">KR</div>
</div>
</div>
<div class="ed-topbar">
<div class="ed-back">&#8592;</div>
<div class="ed-title-label" style="display:flex;align-items:center;gap:6px;">
Lesereise bearbeiten
<span style="font-size:7px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--orange-dark);background:var(--orange-tint);border:1px solid #F0C99A;padding:1px 5px;border-radius:3px;">REISE</span>
</div>
<div class="ed-status-pill ed-status-pub">VERÖFFENTLICHT</div>
<span class="ed-delete-link">Löschen</span>
</div>
<div class="ed-split">
<!-- Left: Journey editor area with items -->
<div class="je-main">
<input class="je-title-input" type="text" value="Briefe aus Breslau 19381942" readonly/>
<div class="je-sep"></div>
<div>
<div class="je-intro-label">Einleitung (optional)</div>
<textarea class="je-intro-area" readonly style="color:var(--color-text);">Der Briefwechsel zwischen Franz Raddatz und seiner Schwester Emma umspannt vier Jahre — von den letzten Friedenssommern bis zum Ende des Krieges.</textarea>
</div>
<div class="je-sep"></div>
<div class="je-list-label">Briefe &amp; Zwischentexte</div>
<!-- Item 1: Document, no note -->
<div class="je-item">
<div class="je-drag">
<div class="je-drag-dots">
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
</div>
</div>
<div class="je-num">1</div>
<div class="je-body">
<div class="je-doc-title">Brief vom 12. Juli 1938</div>
<div class="je-doc-meta">12. Juli 1938 &middot; von Franz Raddatz an Emma Müller</div>
<div class="je-note-add">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
Notiz hinzufügen
</div>
</div>
<div class="je-remove"><div class="je-remove-x">&#215;</div></div>
</div>
<!-- Item 2: Interlude -->
<div class="je-item je-interlude-bg">
<div class="je-drag" style="background:rgba(232,134,42,.08);border-right-color:#F0C99A;">
<div class="je-drag-dots">
<div class="je-drag-dot" style="background:#D4A574;"></div><div class="je-drag-dot" style="background:#D4A574;"></div>
<div class="je-drag-dot" style="background:#D4A574;"></div><div class="je-drag-dot" style="background:#D4A574;"></div>
</div>
</div>
<div class="je-num" style="color:var(--orange-dark);">
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" style="margin-top:7px;"><path d="M2 4h8M2 7h5" stroke="var(--orange)" stroke-width="1.5" stroke-linecap="round"/></svg>
</div>
<div class="je-body" style="padding-top:6px;">
<div style="font-size:7.5px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--orange-dark);margin-bottom:4px;">Zwischentext</div>
<textarea class="je-interlude-area" readonly>Im Sommer 1938 schrieb Franz voller Zuversicht — er hatte kaum eine Ahnung, wie bald sich die Welt um ihn herum verändern würde.</textarea>
</div>
<div class="je-remove"><div class="je-remove-x" style="color:#D4A574;">&#215;</div></div>
</div>
<!-- Item 3: Document with note -->
<div class="je-item">
<div class="je-drag">
<div class="je-drag-dots">
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
</div>
</div>
<div class="je-num">2</div>
<div class="je-body">
<div class="je-doc-title">Postkarte aus Breslau, August 1938</div>
<div class="je-doc-meta" style="margin-bottom:5px;">22. Aug. 1938 &middot; von Franz Raddatz an Emma Müller</div>
<textarea class="je-note-area" readonly>Diese Karte ist ungewöhnlich kurz für Franz — vier Zeilen, fast hastig. Ein Zeichen der aufkommenden Unruhe in den Nachrichten?</textarea>
<div class="je-note-add" style="margin-top:3px;color:var(--color-text-muted);">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M2 2l6 6M8 2l-6 6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
Notiz entfernen
</div>
</div>
<div class="je-remove"><div class="je-remove-x">&#215;</div></div>
</div>
<!-- Item 4: Document, no note -->
<div class="je-item">
<div class="je-drag">
<div class="je-drag-dots">
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
</div>
</div>
<div class="je-num">3</div>
<div class="je-body">
<div class="je-doc-title">Brief vom 3. September 1939</div>
<div class="je-doc-meta">3. Sept. 1939 &middot; von Emma Müller an Franz Raddatz</div>
<div class="je-note-add">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
Notiz hinzufügen
</div>
</div>
<div class="je-remove"><div class="je-remove-x">&#215;</div></div>
</div>
<!-- Add bar -->
<div class="je-add-bar">
<button class="je-add-btn">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
Brief hinzufügen
</button>
<button class="je-add-btn">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
Zwischentext hinzufügen
</button>
</div>
</div>
<!-- Right: Sidebar -->
<div class="ed-sidebar">
<div class="ed-sb-section">
<div class="ed-sb-title">Personen</div>
<div class="ed-search-row">
<span style="font-size:9px;color:var(--color-text-muted);">&#128269;</span>
<div class="ed-search-input">Person suchen…</div>
</div>
<div style="display:flex;flex-wrap:wrap;margin-top:4px;">
<span class="ed-chip">
<span style="width:10px;height:10px;border-radius:50%;background:#012851;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:var(--mint);">FR</span>
Franz Raddatz
<span class="ed-chip-x">&#215;</span>
</span>
<span class="ed-chip">
<span style="width:10px;height:10px;border-radius:50%;background:#534AB7;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:#fff;">EM</span>
Emma Müller
<span class="ed-chip-x">&#215;</span>
</span>
</div>
</div>
<div class="ed-sb-divider"></div>
<div class="ed-sb-section">
<div class="ed-sb-title">Status</div>
<div class="ed-status-pill ed-status-pub" style="font-size:9px;">VERÖFFENTLICHT</div>
<div class="ed-hint" style="margin-top:6px;">Änderungen gehen sofort live.</div>
</div>
</div>
</div>
<div class="ed-savebar">
<span class="ed-savebar-hint">Änderungen sofort live — Leser sehen die aktuelle Version.</span>
<div class="ed-savebar-actions">
<button class="ed-btn-ghost retract">Zurück zu Entwurf</button>
<button class="ed-btn-primary">Speichern</button>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>impl-ref — LE-2 Items-Liste</h4>
<table class="at">
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
<tbody>
<tr class="grp"><td colspan="3">Item-Zeile allgemein</td></tr>
<tr><td>Item-Container</td><td>flex items-stretch bg-white border border-line rounded-sm mb-2 overflow-hidden</td><td>interlude: bg-orange-50 border-orange-200</td></tr>
<tr><td>Drag-Handle</td><td>w-4 bg-surface border-r border-line flex items-center justify-center cursor-grab shrink-0</td><td>aria-label="Reihenfolge ändern"; cursor-grabbing während Drag</td></tr>
<tr><td>Positions-Nr.</td><td>w-5 text-[10px] font-bold text-ink-3 flex items-start justify-center pt-2 shrink-0</td><td>aus Array-Index, nicht item.position</td></tr>
<tr><td>Entfernen-Button</td><td>w-6 flex items-start justify-center pt-2 shrink-0</td><td>× aria-label="Eintrag entfernen"; hover: text-red-500; Confirm nur wenn note vorhanden</td></tr>
<tr class="grp"><td colspan="3">Dokument-Item</td></tr>
<tr><td>Brieftitel</td><td>text-[11px] font-semibold text-ink leading-snug mb-0.5</td><td>document.title</td></tr>
<tr><td>Briefmeta</td><td>text-xs text-ink-3</td><td>formatDate(doc.documentDate) · "von X" oder "von X an Y"</td></tr>
<tr><td>Notiz-Textarea (sichtbar)</td><td>w-full min-h-[40px] font-serif text-xs italic bg-surface border border-line rounded-sm p-1.5 resize-none focus:border-primary focus:bg-white mt-2</td><td>auto-expand; bind:value={item.note}</td></tr>
<tr><td>„Notiz hinzufügen" Link</td><td>text-xs font-semibold text-blue-600 inline-flex items-center gap-1 mt-1</td><td>togglet Notiz-Textarea</td></tr>
<tr><td>„Notiz entfernen" Link</td><td>text-xs text-ink-3 inline-flex items-center gap-1 mt-1</td><td>zeigt sich wenn note.trim() nicht leer; setzt note = '' und blendet Textarea aus</td></tr>
<tr class="grp"><td colspan="3">Interlude-Item</td></tr>
<tr><td>Interlude-Container</td><td>bg-orange-50 border-orange-200 (überschreibt Item-Container)</td><td>kein Positions-Kreis; Positions-Spalte zeigt Icon statt Zahl</td></tr>
<tr><td>Label „Zwischentext"</td><td>text-[9px] font-bold uppercase tracking-widest text-orange-700 mb-1</td><td>immer sichtbar; nicht editierbar</td></tr>
<tr><td>Zwischentext-Textarea</td><td>w-full min-h-[44px] font-serif text-xs italic bg-white/60 border border-orange-200 rounded-sm p-1.5 resize-none focus:border-orange-400</td><td>bind:value={item.note}; auto-expand; min 44px für Touch-Target</td></tr>
<tr class="grp"><td colspan="3">Aktionsleiste</td></tr>
<tr><td>Add Bar</td><td>flex gap-2 pt-2 pb-1</td><td>immer unten sichtbar, auch wenn Liste gefüllt</td></tr>
<tr><td>„Brief hinzufügen" Button</td><td>border border-dashed border-line rounded-sm px-3 py-1.5 text-xs font-semibold text-ink-2 hover:border-primary hover:text-primary flex items-center gap-1</td><td>öffnet existierende DocumentPicker-Komponente als Dropdown/Modal</td></tr>
<tr><td>„Zwischentext hinzufügen" Button</td><td>gleich wie Brief-Button</td><td>fügt neues Interlude-Item am Ende ein; Fokus auf das neue Textarea</td></tr>
<tr class="grp"><td colspan="3">Drag-to-Reorder</td></tr>
<tr><td>Bibliothek</td><td>@dnd-kit/core oder svelte-dnd-action (bereits im Projekt prüfen)</td><td>kein neues Package ohne Absprache</td></tr>
<tr><td>Reorder-API-Call</td><td>PUT /api/geschichten/{id}/items/reorder — body: [{id, position}] für alle Items</td><td>nach jedem Drop ausgelöst; optimistisch: lokalen State sofort aktualisieren</td></tr>
<tr><td>Accessibility</td><td>Drag-Handle: role="button" tabIndex=0; Keyboard: Space startet Drag, Arrow hoch/runter verschiebt, Space/Enter bestätigt, Esc abbricht</td><td>WCAG 2.1 SC 2.1.1</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ═══ SCREEN LE-3: INLINE NOTE EDITING ═══ -->
<div class="section">
<div class="section-title">Screens — Inline-Notiz-Editing</div>
<div class="scr">
<div class="scr-head">
<h3>LE-3 — Notiz-Textarea wird geöffnet</h3>
<span class="scr-id">Issue #753 · LE-3</span>
</div>
<p class="scr-desc">Wenn der Nutzer auf „Notiz hinzufügen" klickt, expandiert das Item um ein Textarea direkt unterhalb der Briefmeta — kein Modal. Der Fokus springt automatisch in das Textarea. Das Textarea hat einen blauen Fokusring als Orientierungshilfe. Ein API-PATCH wird beim Verlassen des Textareas (blur) ausgelöst, nicht bei jedem Tastendruck.</p>
<p class="scr-var"><strong>Inset-Ansicht — kein vollständiger Seiten-Mockup nötig</strong></p>
<div class="previews">
<div class="prev-col" style="width:100%;max-width:560px;">
<span class="bp-lbl">Inset — Notiz-Textarea geöffnet (Fokus)</span>
<div style="background:#E8E7E2;padding:16px;border-radius:var(--radius-xl);">
<!-- Item before (no note) -->
<div class="je-item" style="margin-bottom:5px;">
<div class="je-drag">
<div class="je-drag-dots">
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
</div>
</div>
<div class="je-num">1</div>
<div class="je-body">
<div class="je-doc-title">Brief vom 12. Juli 1938</div>
<div class="je-doc-meta">12. Juli 1938 &middot; Franz → Emma</div>
<div class="je-note-add">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
Notiz hinzufügen
</div>
</div>
<div class="je-remove"><div class="je-remove-x">&#215;</div></div>
</div>
<!-- Item with opened note textarea (focused) -->
<div class="je-item">
<div class="je-drag">
<div class="je-drag-dots">
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
</div>
</div>
<div class="je-num">2</div>
<div class="je-body">
<div class="je-doc-title">Postkarte aus Breslau, August 1938</div>
<div class="je-doc-meta" style="margin-bottom:5px;">22. Aug. 1938 &middot; Franz → Emma</div>
<!-- Focused textarea -->
<textarea class="je-note-area je-note-editing" style="outline:none;box-shadow:0 0 0 2px rgba(1,40,81,.2);" readonly placeholder="Kuratoren-Notiz für diesen Brief…">|</textarea>
<div style="font-size:7px;color:var(--color-text-muted);margin-top:3px;">Wird gespeichert, wenn du das Feld verlässt.</div>
<div class="je-note-add" style="color:var(--color-text-muted);margin-top:2px;">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M2 2l6 6M8 2l-6 6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
Notiz entfernen
</div>
</div>
<div class="je-remove"><div class="je-remove-x">&#215;</div></div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>impl-ref — LE-3 Inline-Notiz</h4>
<table class="at">
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
<tbody>
<tr class="grp"><td colspan="3">Toggleverhalten</td></tr>
<tr><td>Lokaler State</td><td>let noteOpen = item.note !== null and item.note !== ''</td><td>öffnet sich automatisch wenn Notiz bereits vorhanden</td></tr>
<tr><td>„Notiz hinzufügen" Klick</td><td>noteOpen = true; tick().then(() => noteTextarea.focus())</td><td>Fokus nach Svelte-Tick um DOM-Update abzuwarten</td></tr>
<tr><td>Textarea blur-Handler</td><td>on:blur={() => saveNote(item.id, note)}</td><td>PATCH /api/geschichten/{id}/items/{itemId} mit {note}</td></tr>
<tr><td>Leere Notiz on blur</td><td>wenn note.trim() === '' → noteOpen = false; note = null</td><td>verhindert leere Notizen im Backend</td></tr>
<tr class="grp"><td colspan="3">Fokus-Styling</td></tr>
<tr><td>Fokus-Ring</td><td>focus:border-primary focus:ring-2 focus:ring-primary/20 focus:bg-white</td><td>sichtbarer Ring für Keyboard-Navigation; ring-offset für Abstand</td></tr>
<tr><td>Spar-Hint</td><td>text-[9px] text-ink-3 mt-1</td><td>„Wird gespeichert, wenn du das Feld verlässt."; verschwindet wenn noteOpen = false</td></tr>
<tr class="grp"><td colspan="3">Barrierefreiheit</td></tr>
<tr><td>aria-label Textarea</td><td>aria-label="Kuratoren-Notiz für {document.title}"</td><td>spezifisch; Screen-Reader nennt Brief-Kontext</td></tr>
<tr><td>aria-expanded Toggle</td><td>aria-expanded={noteOpen} auf „Notiz hinzufügen"-Button</td><td>kommuniziert Expand-State</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ═══ SCREEN LE-4: MOBILE ═══ -->
<div class="section">
<div class="section-title">Screens — Mobile Editor</div>
<div class="scr">
<div class="scr-head">
<h3>LE-4 — Mobile Journey-Editor</h3>
<span class="scr-id">Issue #753 · LE-4</span>
</div>
<p class="scr-desc">Auf Mobile (320px) entfällt die Sidebar-Split. Die Personen- und Status-Sektion werden als ausklappbare Sektionen unter der Itemliste gezeigt. Drag-to-Reorder ist auf Mobile durch Long-Press aktiviert. Die Aktionsleiste scrollt mit dem Inhalt.</p>
<p class="scr-var"><strong>Primäre Zielgruppe für den Editor: Desktop/Tablet. Mobile ist sekundär — alle Funktionen erreichbar, aber Drag ist schwerer bedienbar.</strong></p>
<div class="previews">
<div class="prev-col">
<span class="bp-lbl">Mobile — 320px · mit Einträgen</span>
<div class="phone" style="min-height:580px;">
<div class="pst"><b>9:41</b><span>&#9679;&#9679;&#9679;</span></div>
<div class="pb">
<div class="m-nav">
<span class="m-logo">ARCHIV</span>
<div class="m-nav-r">
<div class="m-av">KR</div>
<div class="m-ham"><span></span><span></span><span></span></div>
</div>
</div>
<div class="mob-topbar">
<span class="mob-back">&#8592;</span>
<span class="mob-label">Lesereise bearbeiten</span>
<div class="ed-status-pill ed-status-pub" style="font-size:7px;padding:1px 5px;">VER&Ouml;FF.</div>
</div>
<div class="mob-body">
<input class="mob-title-input" type="text" value="Briefe aus Breslau 19381942" readonly/>
<!-- Item 1: Document -->
<div class="mob-je-item">
<div class="mob-je-drag">
<div class="je-drag-dots">
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
</div>
</div>
<div class="mob-je-body">
<div class="mob-je-title">Brief vom 12. Juli 1938</div>
<div class="mob-je-meta">12. Juli 1938 &middot; Franz → Emma</div>
</div>
<div style="padding:6px 6px 0 0;font-size:10px;color:#C4C3BC;">&#215;</div>
</div>
<!-- Item 2: Interlude -->
<div class="mob-je-item mob-je-interlude">
<div class="mob-je-drag" style="background:rgba(232,134,42,.08);border-right-color:#F0C99A;"></div>
<div class="mob-je-body">
<div style="font-size:6.5px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--orange-dark);margin-bottom:3px;">Zwischentext</div>
<div class="mob-je-interlude-text">Im Sommer 1938 schrieb Franz voller Zuversicht…</div>
</div>
<div style="padding:6px 6px 0 0;font-size:10px;color:#D4A574;">&#215;</div>
</div>
<!-- Item 3: Document with note -->
<div class="mob-je-item">
<div class="mob-je-drag"></div>
<div class="mob-je-body">
<div class="mob-je-title">Postkarte Aug. 1938</div>
<div class="mob-je-meta">22. Aug. 1938 &middot; Franz → Emma</div>
<div class="mob-je-note">Diese Karte ist ungewöhnlich kurz für Franz…</div>
</div>
<div style="padding:6px 6px 0 0;font-size:10px;color:#C4C3BC;">&#215;</div>
</div>
<!-- Add bar -->
<div style="display:flex;gap:5px;padding:4px 0;">
<button class="je-add-btn" style="flex:1;font-size:7.5px;padding:6px 8px;justify-content:center;">+ Brief</button>
<button class="je-add-btn" style="flex:1;font-size:7.5px;padding:6px 8px;justify-content:center;">+ Zwischentext</button>
</div>
<!-- Collapsible: Personen -->
<div class="mob-collapsible">
<div class="mob-coll-hdr">
Personen
<span class="mob-coll-chevron">&#8250;</span>
</div>
</div>
<!-- Collapsible: Status -->
<div class="mob-collapsible">
<div class="mob-coll-hdr">
Status &amp; Speichern
<span class="mob-coll-chevron">&#8250;</span>
</div>
</div>
</div>
<div class="mob-savebar">
<button class="mob-btn mob-btn-ghost" style="font-size:8px;flex:0 0 auto;padding:7px 10px;">Entwurf</button>
<button class="mob-btn mob-btn-primary">Speichern</button>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>impl-ref — LE-4 Mobile</h4>
<table class="at">
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
<tbody>
<tr class="grp"><td colspan="3">Layout-Anpassungen</td></tr>
<tr><td>Split entfällt</td><td>@media (max-width: 768px): flex-col; Sidebar-Sektionen als Collapsibles am Ende</td><td>gleich wie GeschichteEditor auf Mobile</td></tr>
<tr><td>Collapsibles</td><td>details/summary oder eigene boolean-Toggle; Personen + Status separat</td><td>geschlossen beim ersten Laden; Fokus öffnet</td></tr>
<tr class="grp"><td colspan="3">Touch &amp; Drag</td></tr>
<tr><td>Drag auf Mobile</td><td>Long-Press (500ms) auf dem Drag-Handle aktiviert Drag</td><td>dnd-kit unterstützt Touch nativ; kein separates Config nötig</td></tr>
<tr><td>Touch Target Items</td><td>min-h-[44px] für jede Item-Zeile</td><td>WCAG 2.2 AA; durch Padding gesichert</td></tr>
<tr><td>Add-Buttons</td><td>flex-1; volle verfügbare Breite geteilt</td><td>min-h-[44px] als Touch-Target</td></tr>
<tr class="grp"><td colspan="3">Savebar</td></tr>
<tr><td>Savebar Mobile</td><td>flex gap-2; „Zurück zu Entwurf" komprimiert zu „Entwurf"</td><td>Volltext passt nicht auf 320px</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ═══ LLM IMPLEMENTATION GUIDE ═══ -->
<div class="llm">
<h2>Implementation Guide — Journey-Editor</h2>
<h3>Neue Komponente</h3>
<table>
<thead><tr><th>Datei</th><th>Typ</th><th>Beschreibung</th></tr></thead>
<tbody>
<tr><td><code>src/lib/geschichte/JourneyEditor.svelte</code></td><td>Svelte-Komponente</td><td>Hauptkomponente; Props: <code>geschichte: Geschichte</code></td></tr>
<tr><td><code>src/lib/geschichte/JourneyItemRow.svelte</code></td><td>Svelte-Komponente</td><td>Eine Zeile (Dokument oder Interlude); Props: <code>item: JourneyItem, position: number</code>, Events: <code>remove, noteChange</code></td></tr>
</tbody>
</table>
<h3>Edit-Page-Integration</h3>
<ul>
<li><code>GeschichteEditor.svelte</code> erhält ein neues Prop <code>type: GeschichteType</code>.</li>
<li>Wenn <code>type === 'JOURNEY'</code>: rendere <code>JourneyEditor</code> statt TipTap-Editor. Die Sidebar (Personen, Status, Savebar) bleibt identisch.</li>
<li>Die Savebar-Logik ist in der Edit-Page (<code>+page.svelte</code>) verankert — <code>JourneyEditor</code> gibt nur Änderungen nach oben (Svelte-Events oder bindable Props), die Seite hält den Save-State.</li>
</ul>
<h3>API-Calls</h3>
<table>
<thead><tr><th>Aktion</th><th>Endpoint</th><th>Body</th></tr></thead>
<tbody>
<tr><td>Brief hinzufügen</td><td><code>POST /api/geschichten/{id}/items</code></td><td><code>{documentId: UUID}</code></td></tr>
<tr><td>Zwischentext hinzufügen</td><td><code>POST /api/geschichten/{id}/items</code></td><td><code>{note: string}</code></td></tr>
<tr><td>Notiz speichern/bearbeiten</td><td><code>PATCH /api/geschichten/{id}/items/{itemId}</code></td><td><code>{note: string | null}</code></td></tr>
<tr><td>Item entfernen</td><td><code>DELETE /api/geschichten/{id}/items/{itemId}</code></td><td></td></tr>
<tr><td>Reihenfolge speichern</td><td><code>PUT /api/geschichten/{id}/items/reorder</code></td><td><code>[{id: UUID, position: number}]</code></td></tr>
</tbody>
</table>
<h3>Optimistische Updates</h3>
<ul>
<li>Alle Mutationen (add, remove, reorder, noteChange) aktualisieren den lokalen State <em>sofort</em>, der API-Call läuft parallel.</li>
<li>Bei Fehler: lokalen State zurückrollen und einen <code>aria-live="polite"</code>-Fehlerhinweis anzeigen.</li>
<li>Notiz-Saving ist ein Sonderfall: es gibt kein optimistisches Update da der Wert bereits live im Textarea ist — nur blur → PATCH.</li>
</ul>
<h3>DocumentPicker-Integration</h3>
<ul>
<li>Der „Brief hinzufügen"-Button öffnet die bestehende <code>DocumentPicker</code>-Komponente (prüfe <code>$lib/document/</code> auf vorhandene Typeahead-Komponenten).</li>
<li>Nach Auswahl eines Dokuments: <code>POST /items</code> mit <code>documentId</code>, neues Item wird an das Ende der Liste angehängt und eingeblendet.</li>
<li>Bereits in der Journey enthaltene Dokumente: in der Picker-Ergebnisliste mit einem „Bereits enthalten"-Hinweis markieren und deaktivieren.</li>
</ul>
<h3>Drag-to-Reorder</h3>
<ul>
<li>Bibliothek: prüfe zunächst ob <code>@dnd-kit/core</code> oder <code>svelte-dnd-action</code> bereits im <code>package.json</code> ist. Kein neues Package einführen ohne Absprache.</li>
<li>Nach dem Drop: neue Reihenfolge als Array <code>[{id, position}]</code> berechnen (position = index * 10 lässt Lücken für künftige Inserts) und <code>PUT /items/reorder</code> senden.</li>
<li>Keyboard-Drag: Space/Enter startet, Arrow Up/Down verschiebt, Space/Enter bestätigt, Escape abbricht. Screenreader-Announcement: „Eintrag X von Position Y nach Z verschoben".</li>
</ul>
<h3>Barrierefreiheit</h3>
<ul>
<li>Items-Liste: <code>&lt;ol&gt;</code>-Element — kommuniziert die Ordnung an Screenreader.</li>
<li>Drag-Handle: <code>role="button"</code>, <code>tabindex="0"</code>, <code>aria-label="Reihenfolge von '{title}' ändern"</code>.</li>
<li>Entfernen-Button: <code>aria-label="'{title}' entfernen"</code>; kein reines ×-Zeichen ohne Label.</li>
<li>Notiz-Textarea: <code>aria-label="Kuratoren-Notiz für '{title}'"</code>.</li>
<li>Touch-Targets: alle interaktiven Elemente min 44×44px (WCAG 2.2 AA).</li>
<li>Fokusring: <code>focus-visible:ring-2 focus-visible:ring-primary</code> auf allen Buttons und Textareas.</li>
</ul>
<h3>Abgrenzung zu GeschichteEditor</h3>
<ul>
<li>TipTap wird für JOURNEY <em>nicht</em> geladen — kein unnötiger Bundle-Load.</li>
<li>Die Sidebar (Personen, Status) ist für beide Typen identisch — kein Duplikat, die Sidebar-Komponente wird geteilt.</li>
<li>Savebar-Logik (DRAFT/PUBLISHED/Retract) ist identisch — JourneyEditor ändert sie nicht.</li>
<li><code>Geschichte.body</code> dient für JOURNEY als Einleitungstext (Plaintext, kein HTML). Kein Rich-Text-Rendering auf der Leseseite nötig.</li>
</ul>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,727 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Lesereisen — Reader-Integration · Familienarchiv</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<style>
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--navy:#012851;--mint:#A1DCD8;--sand:#F0EFE9;--turquoise:#00C7B1;--blue-tint:#E6F1FB;--blue:#2D7DD2;--blue-dark:#185FA5;--green-tint:#E8F5EA;--green:#3D8C4A;--green-dark:#2E6E39;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.doc-header p{font-size:13px;color:var(--color-text-muted);max-width:680px;}
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;}
.pill-o{background:var(--orange-tint);color:var(--orange-dark);}
.section{margin-bottom:64px;}
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
.jh{padding:20px 24px;border-radius:var(--radius-xl);margin-bottom:40px;display:flex;align-items:center;gap:16px;}
.jh .jn{font-family:var(--font-display);font-size:48px;font-weight:300;line-height:1;opacity:.5;}
.jh h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.jh p{font-size:13px;line-height:1.5;}.jh .fl{font-family:var(--font-mono);font-size:11px;margin-top:6px;opacity:.7;}
.jh-o{background:var(--orange-tint);border:1px solid #F0C99A;}
.jh-o .jn{color:var(--orange);}
.jh-o p,.jh-o .fl{color:var(--orange-dark);}
.scr{margin-bottom:56px;}
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;flex-direction:column;}
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
.fa-nav{height:32px;background:var(--navy);display:flex;align-items:center;padding:0 12px;gap:8px;flex-shrink:0;}
.fa-logo{font-size:7px;font-weight:900;color:#fff;letter-spacing:.8px;border-bottom:2px solid var(--mint);padding-bottom:1px;}
.fa-link{font-size:5.5px;color:rgba(255,255,255,.4);font-weight:700;text-transform:uppercase;letter-spacing:.05em;}
.fa-link.active{color:var(--mint);}
.fa-nav-r{margin-left:auto;display:flex;gap:5px;align-items:center;}
.fa-av{width:16px;height:16px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5);}
.m-nav{height:26px;background:var(--navy);display:flex;align-items:center;padding:0 10px;gap:6px;flex-shrink:0;}
.m-logo{font-size:6px;font-weight:900;color:#fff;letter-spacing:.7px;border-bottom:1.5px solid var(--mint);padding-bottom:1px;}
.m-nav-r{margin-left:auto;display:flex;gap:4px;align-items:center;}
.m-av{width:14px;height:14px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:rgba(255,255,255,.5);}
.m-ham{display:flex;flex-direction:column;gap:2px;width:12px;}
.m-ham span{height:1.5px;background:rgba(255,255,255,.6);border-radius:1px;}
/* ── impl-ref table ── */
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
.at thead tr{border-bottom:1px solid #2A2A26;}
.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}
.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}
.at tr:last-child td{border-bottom:none;}
.at td:first-child{color:#7A7A72;}
.at td:nth-child(2){color:#E8E8E2;font-weight:500;}
.at td:nth-child(3){color:#5A5A55;}
.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
/* ── LLM guide ── */
.llm{background:var(--color-page);border:2px solid var(--navy);border-radius:var(--radius-xl);padding:32px 40px;margin-top:64px;}
.llm h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;color:var(--navy);}
.llm h3{font-size:14px;font-weight:600;margin:20px 0 8px;}
.llm h4{font-size:12px;font-weight:600;margin:14px 0 6px;color:var(--color-text-muted);}
.llm p,.llm li{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
.llm li{margin-bottom:4px;}
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:1px 5px;border-radius:3px;}
.llm table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}
.llm th,.llm td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--color-border);}
.llm th{font-weight:500;color:var(--color-text);font-size:11px;text-transform:uppercase;letter-spacing:.05em;}
.llm td{color:var(--color-text-muted);}
/* ── List row (re-used from reader-journey spec) ── */
.g-list-card{background:#fff;border:1px solid #E4E2D7;border-radius:4px;box-shadow:var(--shadow-card);overflow:hidden;}
.g-row{display:flex;gap:0;border-bottom:1px solid #F0EFE9;}
.g-row:last-child{border-bottom:none;}
.g-meta{width:88px;flex-shrink:0;padding:10px 10px 10px 12px;display:flex;flex-direction:column;gap:3px;border-right:1px solid #F0EFE9;}
.g-content{padding:10px 14px 10px 12px;flex:1;min-width:0;}
.g-av{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:7px;font-weight:800;color:#fff;flex-shrink:0;margin-bottom:3px;}
.av-navy{background:#012851;} .av-purple{background:#534AB7;} .av-teal{background:#0E9488;}
.g-author{font-size:7px;font-weight:700;color:#1C1C18;line-height:1.3;}
.g-date{font-size:6.5px;color:#6B6A63;}
.g-chip{display:inline-flex;align-items:center;gap:2px;padding:1px 5px;background:#F5F4EE;border:1px solid #D8D7D0;border-radius:10px;font-size:6px;font-weight:500;color:#1C1C18;margin-top:2px;max-width:76px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.g-title{font-family:Georgia,serif;font-size:11px;color:#012851;line-height:1.4;margin-bottom:2px;}
.g-excerpt{font-size:7.5px;color:#6B6A63;line-height:1.55;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}
.g-filters{display:flex;gap:5px;align-items:center;padding:8px 12px;background:var(--color-page);border-bottom:1px solid #EDECEA;flex-wrap:wrap;}
.g-pill{display:inline-flex;align-items:center;padding:2px 8px;border-radius:10px;font-size:6.5px;font-weight:700;border:1px solid #D8D7D0;color:#6B6A63;background:transparent;}
.g-pill.active{background:#012851;color:#fff;border-color:#012851;}
.g-page-hdr{display:flex;justify-content:space-between;align-items:center;padding:10px 14px 6px;}
.g-page-title{font-family:Georgia,serif;font-size:16px;font-weight:400;color:#012851;}
.g-new-btn{font-size:7px;font-weight:700;padding:4px 10px;border-radius:3px;background:#012851;color:#fff;border:none;display:flex;align-items:center;gap:3px;}
/* ── Journey badge in list ── */
.j-badge{display:inline-flex;align-items:center;padding:1px 5px;border-radius:3px;font-size:5.5px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;background:var(--orange-tint);color:var(--orange-dark);border:1px solid #F0C99A;margin-top:2px;}
/* ── Type selector cards ── */
.type-selector{display:flex;gap:12px;justify-content:center;padding:20px 24px;flex:1;align-items:center;background:#E8E7E2;}
.type-selector-inner{max-width:520px;width:100%;}
.type-selector-q{font-family:Georgia,serif;font-size:12px;font-weight:400;color:#6B6A63;text-align:center;margin-bottom:14px;}
.type-cards{display:flex;gap:10px;}
.type-card{flex:1;border:1px solid #D8D7D0;border-radius:6px;padding:12px 14px;cursor:pointer;background:#fff;display:flex;flex-direction:column;gap:5px;}
.type-card.selected{border-color:var(--orange);background:var(--orange-tint);box-shadow:0 0 0 2px rgba(232,134,42,.15);}
.type-card-icon{font-size:16px;margin-bottom:2px;}
.type-card-title{font-family:Georgia,serif;font-size:11px;font-weight:400;color:var(--navy);}
.type-card-desc{font-size:7.5px;color:#6B6A63;line-height:1.55;}
.type-card-check{width:14px;height:14px;border-radius:50%;background:var(--orange);display:flex;align-items:center;justify-content:center;margin-top:4px;align-self:flex-end;}
.type-card-check svg{width:8px;height:8px;}
.type-next-bar{display:flex;justify-content:flex-end;padding:8px 24px;background:#fff;border-top:1px solid #E4E2D7;}
.type-next-btn{font-size:8px;font-weight:700;padding:5px 14px;border-radius:3px;background:var(--navy);color:#fff;border:none;display:flex;align-items:center;gap:3px;}
/* ── Journey reader ── */
.jr-article{background:var(--color-page);border-radius:6px;padding:16px 20px;max-width:640px;margin:0 auto;}
.jr-back{font-size:7px;color:#6B6A63;margin-bottom:10px;display:flex;align-items:center;gap:2px;}
.jr-badge{display:inline-flex;align-items:center;padding:1px 6px;border-radius:3px;font-size:6px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;background:var(--orange-tint);color:var(--orange-dark);border:1px solid #F0C99A;margin-bottom:5px;}
.jr-title{font-family:Georgia,serif;font-size:18px;font-weight:400;color:#012851;line-height:1.3;margin-bottom:8px;}
.jr-metabar{display:flex;align-items:center;gap:6px;padding-bottom:8px;border-bottom:1px solid #EDECEA;margin-bottom:10px;}
.jr-metabar-r{margin-left:auto;display:flex;align-items:center;gap:6px;}
.jr-edit-btn{font-size:6.5px;font-weight:600;padding:2px 7px;border:1px solid #D8D7D0;border-radius:3px;color:#1C1C18;background:transparent;}
.jr-intro{font-family:Georgia,serif;font-size:8.5px;line-height:1.75;color:#6B6A63;font-style:italic;margin-bottom:12px;padding-bottom:10px;border-bottom:1px dashed #EDECEA;}
/* Journey items in reader */
.jr-item{display:flex;gap:7px;margin-bottom:9px;align-items:flex-start;}
.jr-num{width:18px;height:18px;border-radius:50%;background:#012851;color:#fff;display:flex;align-items:center;justify-content:center;font-size:7px;font-weight:700;flex-shrink:0;margin-top:1px;}
.jr-card{flex:1;background:#fff;border:1px solid #E4E2D7;border-radius:4px;padding:7px 9px;}
.jr-card-title{font-family:Georgia,serif;font-size:9px;color:#012851;line-height:1.3;margin-bottom:2px;font-weight:400;}
.jr-card-meta{font-size:6.5px;color:#6B6A63;margin-bottom:5px;}
.jr-card-link{font-size:7px;font-weight:600;color:#012851;display:flex;align-items:center;gap:2px;}
.jr-annotation{margin-top:6px;padding:5px 7px;border-left:2px solid var(--mint);background:#F5F4EE;border-radius:0 3px 3px 0;}
.jr-annotation-text{font-size:7.5px;font-style:italic;color:#6B6A63;line-height:1.55;}
.jr-interlude{margin:10px 0 10px 25px;padding:7px 9px;border-left:2px solid var(--orange);background:var(--orange-tint);border-radius:0 4px 4px 0;}
.jr-interlude-text{font-size:8px;font-style:italic;color:#1C1C18;line-height:1.65;}
/* Mobile list row */
.m-row{padding:9px 10px;border-bottom:1px solid #F0EFE9;background:#fff;}
.m-row-top{display:flex;align-items:center;gap:5px;margin-bottom:3px;}
.m-author-name{font-size:7px;font-weight:700;color:#1C1C18;}
.m-date{font-size:6.5px;color:#6B6A63;margin-left:auto;}
.m-title{font-family:Georgia,serif;font-size:10px;color:#012851;line-height:1.4;margin-bottom:2px;}
.m-excerpt{font-size:7px;color:#6B6A63;line-height:1.5;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}
.m-filters{display:flex;gap:4px;padding:6px 10px;background:var(--color-page);border-bottom:1px solid #EDECEA;overflow-x:auto;flex-wrap:nowrap;}
.m-filters::-webkit-scrollbar{display:none;}
/* Mobile journey reader */
.mjr-article{background:#fff;border-radius:6px;padding:12px 12px 16px;}
.mjr-back{font-size:7px;color:#6B6A63;margin-bottom:7px;display:flex;align-items:center;gap:2px;}
.mjr-badge{display:inline-flex;padding:1px 5px;border-radius:3px;font-size:5.5px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;background:var(--orange-tint);color:var(--orange-dark);border:1px solid #F0C99A;margin-bottom:4px;}
.mjr-title{font-family:Georgia,serif;font-size:14px;font-weight:400;color:#012851;line-height:1.3;margin-bottom:6px;}
.mjr-metabar{display:flex;align-items:center;gap:5px;padding-bottom:6px;border-bottom:1px solid #EDECEA;margin-bottom:8px;}
.mjr-intro{font-family:Georgia,serif;font-size:8px;line-height:1.7;color:#6B6A63;font-style:italic;margin-bottom:9px;padding-bottom:7px;border-bottom:1px dashed #EDECEA;}
.mjr-item{display:flex;gap:5px;margin-bottom:7px;align-items:flex-start;}
.mjr-num{width:14px;height:14px;border-radius:50%;background:#012851;color:#fff;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:700;flex-shrink:0;margin-top:1px;}
.mjr-card{flex:1;background:#F5F4EE;border:1px solid #E4E2D7;border-radius:4px;padding:5px 7px;}
.mjr-card-title{font-family:Georgia,serif;font-size:8.5px;color:#012851;line-height:1.3;margin-bottom:1px;}
.mjr-card-meta{font-size:6px;color:#6B6A63;margin-bottom:4px;}
.mjr-card-link{font-size:6.5px;font-weight:600;color:#012851;}
.mjr-interlude{margin:7px 0 7px 19px;padding:5px 7px;border-left:2px solid var(--orange);background:var(--orange-tint);border-radius:0 3px 3px 0;}
.mjr-interlude-text{font-size:7.5px;font-style:italic;color:#1C1C18;line-height:1.6;}
/* ── Editor topbar (type selector screen) ── */
.ed-topbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:0 14px;gap:8px;height:38px;flex-shrink:0;}
.ed-back{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--color-text-muted);flex-shrink:0;}
.ed-title-label{font-family:var(--font-sans);font-size:10px;font-weight:500;color:var(--color-text);flex:1;}
.ed-status-pill{display:inline-flex;align-items:center;padding:2px 7px;border-radius:20px;font-size:8px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;flex-shrink:0;}
.ed-status-draft{background:#F0EFE9;color:#6B6A63;border:1px solid #D8D7D0;}
@media(max-width:900px){.doc{padding:24px 16px 80px;}}
</style>
</head>
<body>
<div class="doc">
<!-- ═══ DOC HEADER ═══ -->
<div class="doc-header">
<div>
<h1>Lesereisen — Reader-Integration</h1>
<p>Typauswahl bei <code>/geschichten/new</code>, Journey-Badge auf der Übersichtsliste und die neue geordnete Leseansicht auf <code>/geschichten/[id]</code> wenn <code>type === 'JOURNEY'</code>. Bestehende Story-Ansichten bleiben unverändert.</p>
</div>
<div class="doc-meta">
Familienarchiv<br/>
<span class="pill pill-o">Final Spec</span><br/>
2026-06-07 &middot; @leonievoss<br/>
<span style="font-size:10px;margin-top:4px;display:inline-block;">Issue #752</span>
</div>
</div>
<!-- ═══ JOURNEY HEADER ═══ -->
<div class="jh jh-o">
<div class="jn">R</div>
<div>
<h2>Lesereisen — Reader</h2>
<p>Alle angemeldeten Familienmitglieder können Lesereisen entdecken und in Briefsequenzen mit Kuratoren-Notizen eintauchen. BLOG_WRITERs sehen zusätzlich Bearbeiten/Löschen-Aktionen.</p>
<div class="fl">/geschichten &middot; /geschichten/new &middot; /geschichten/[id]</div>
</div>
</div>
<!-- ═══ KONZEPT ═══ -->
<div class="section">
<div class="section-title">Konzept</div>
<p class="prose">Eine <em>Lesereise</em> ist eine <code>Geschichte</code> mit <code>type === 'JOURNEY'</code>. Ihr Kerninhalt ist eine geordnete Sequenz von Briefen (<code>JourneyItem</code>s mit <code>document_id</code>) und Zwischentexten (<code>JourneyItem</code>s ohne <code>document_id</code>). Das optionale Feld <code>body</code> dient als Einleitung/Preface.</p>
<p class="prose">Diese Spec deckt drei Änderungen ab: (1) die Typauswahl auf <code>/geschichten/new</code> als vorgelagerter Schritt, (2) das „REISE"-Badge in der Übersichtsliste, und (3) die neue Journey-Leseansicht auf der Detailseite, die den bestehenden Prosa-Body durch eine nummerierte Briefliste ersetzt.</p>
<p class="prose">Dokument-Items zeigen Titel, Datum, Sender→Empfänger und einen Link zum Brief. Optionale Kuratoren-Notizen erscheinen als Annotation mit Mint-Linker-Rand unter dem Briefeintrag. Interlude-Items (kein Dokument) erscheinen als eingerückte Absätze mit orangenem linken Rand — klar vom Dokumenttyp unterscheidbar, aber harmonisch im Lesefluss.</p>
</div>
<!-- ═══ SCREEN LR-0: TYPE SELECTOR ═══ -->
<div class="section">
<div class="section-title">Screens — Typauswahl</div>
<div class="scr">
<div class="scr-head">
<h3>LR-0 — Typauswahl /geschichten/new</h3>
<span class="scr-id">Issue #752 · LR-0</span>
</div>
<p class="scr-desc">Neuer vorgelagerter Schritt beim Erstellen einer Geschichte. Zwei Karten zur Auswahl: „Geschichte" (Prosa) und „Lesereise" (Briefsequenz). Die ausgewählte Karte wird hervorgehoben. Erst nach Auswahl wird der „Weiter"-Button aktiv. Auswahl bleibt im URL-Param erhalten (<code>?type=JOURNEY</code>).</p>
<p class="scr-var"><strong>Varianten:</strong> Keine Auswahl (Weiter-Button inaktiv) · Lesereise gewählt (hier gezeigt) · Geschichte gewählt</p>
<div class="previews">
<div class="prev-col" style="width:100%;max-width:1040px;">
<span class="bp-lbl">Desktop — 1040px · Lesereise gewählt</span>
<div class="desk" style="min-height:320px;">
<div class="fa-nav">
<span class="fa-logo">ARCHIV</span>
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
<span class="fa-link">Dokumente</span>
<span class="fa-link">Personen</span>
<span class="fa-link active">Geschichten</span>
<span class="fa-link">Chronik</span>
<div class="fa-nav-r">
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">MR</div>
</div>
</div>
<div class="ed-topbar">
<div class="ed-back">&#8592;</div>
<div class="ed-title-label">Neue Geschichte</div>
<div class="ed-status-pill ed-status-draft">ENTWURF</div>
</div>
<div class="type-selector">
<div class="type-selector-inner">
<div class="type-selector-q">Was möchtest du erstellen?</div>
<div class="type-cards">
<!-- Story card -->
<div class="type-card">
<div class="type-card-icon">✍️</div>
<div class="type-card-title">Geschichte</div>
<div class="type-card-desc">Freier Prosatext über Familienerlebnisse, Erinnerungen oder historische Einordnungen — mit verlinkten Personen und Dokumenten.</div>
</div>
<!-- Journey card (selected) -->
<div class="type-card selected">
<div class="type-card-icon">📜</div>
<div class="type-card-title">Lesereise</div>
<div class="type-card-desc">Geordnete Briefsequenz mit optionalen Kuratoren-Notizen zwischen den Briefen — für chronologische Korrespondenz-Sammlungen.</div>
<div class="type-card-check">
<svg viewBox="0 0 10 10" fill="none"><path d="M2 5l2.5 2.5L8 3" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
</div>
</div>
</div>
</div>
<div class="type-next-bar">
<button class="type-next-btn">
Weiter
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M4 2l4 3-4 3" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>impl-ref — LR-0 Typauswahl</h4>
<table class="at">
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
<tbody>
<tr class="grp"><td colspan="3">Layout</td></tr>
<tr><td>Selector area</td><td>flex flex-1 items-center justify-center bg-canvas px-6 py-10</td><td>zentriert, füllt restliche Höhe</td></tr>
<tr><td>Frage</td><td>font-serif text-sm text-ink-2 text-center mb-4</td><td></td></tr>
<tr><td>Karten-Grid</td><td>flex gap-4</td><td>2 gleich breite Karten; auf Mobile flex-col</td></tr>
<tr class="grp"><td colspan="3">Type-Karte</td></tr>
<tr><td>Karte (inaktiv)</td><td>border border-line rounded-md p-4 bg-white cursor-pointer hover:border-primary hover:bg-surface</td><td>focus-visible:ring-2 focus-visible:ring-primary</td></tr>
<tr><td>Karte (ausgewählt)</td><td>border-2 border-orange-500 bg-orange-50 shadow-sm</td><td>aria-pressed="true"; kein Tailwind-Kürzel — nutze CSS-var(--orange)</td></tr>
<tr><td>Check-Kreis</td><td>w-5 h-5 rounded-full bg-orange-500 flex items-center justify-center self-end mt-2</td><td>nur sichtbar wenn ausgewählt</td></tr>
<tr><td>Kartentitel</td><td>font-serif text-sm text-ink</td><td></td></tr>
<tr><td>Kartenbeschreibung</td><td>text-xs text-ink-3 leading-relaxed mt-1</td><td></td></tr>
<tr class="grp"><td colspan="3">Navigation</td></tr>
<tr><td>Weiter-Button</td><td>rounded border border-primary bg-primary text-white px-4 py-2 text-sm font-medium disabled:opacity-40</td><td>disabled wenn keine Karte ausgewählt</td></tr>
<tr><td>URL-Param</td><td>?type=STORY | ?type=JOURNEY</td><td>per goto() nach Klick auf Weiter; lesefreundlich bookmarkbar</td></tr>
<tr><td>Mobile</td><td>flex-col Karten; volle Breite</td><td>kein Scrollbedarf auf 320px</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ═══ SCREEN LR-1: LIST WITH BADGE ═══ -->
<div class="section">
<div class="section-title">Screens — Übersichtsliste</div>
<div class="scr">
<div class="scr-head">
<h3>LR-1 — Reise-Badge in /geschichten</h3>
<span class="scr-id">Issue #752 · LR-1</span>
</div>
<p class="scr-desc">Die Übersichtsliste erhält ein kleines „REISE"-Badge in der Metaspalte einer Journey-Zeile — unterhalb von Datum und Personenchip. Zeilen mit <code>type === 'STORY'</code> bleiben unverändert. Das Badge ist nicht klickbar, dient als reine visuelle Unterscheidung.</p>
<p class="scr-var"><strong>Varianten:</strong> Mischte Liste (hier gezeigt) · Nur-Journey-Filter · Nur-Story-Ansicht (unverändert)</p>
<div class="previews">
<!-- Desktop -->
<div class="prev-col" style="width:100%;max-width:1040px;">
<span class="bp-lbl">Desktop — 1040px · gemischte Liste</span>
<div class="desk">
<div class="fa-nav">
<span class="fa-logo">ARCHIV</span>
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
<span class="fa-link">Dokumente</span>
<span class="fa-link">Personen</span>
<span class="fa-link active">Geschichten</span>
<span class="fa-link">Chronik</span>
<div class="fa-nav-r">
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">MR</div>
</div>
</div>
<div style="background:#E8E7E2;flex:1;padding:14px 16px;">
<div class="g-page-hdr" style="padding:0 0 8px;">
<span class="g-page-title">Geschichten</span>
<button class="g-new-btn">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="#fff" stroke-width="2" stroke-linecap="round"/></svg>
Neue Geschichte
</button>
</div>
<div class="g-list-card">
<div class="g-filters">
<span class="g-pill active">Alle</span>
<span class="g-pill">Franz Raddatz</span>
<span class="g-pill">Emma Müller</span>
<span class="g-pill" style="border-style:dashed;color:#6B6A63;">+ Person wählen</span>
</div>
<!-- Row 1: Story (no badge) -->
<div class="g-row">
<div class="g-meta">
<div class="g-av av-navy">MR</div>
<div class="g-author">Maria Raddatz</div>
<div class="g-date">14. März 2025</div>
<span class="g-chip">
<span style="width:8px;height:8px;border-radius:50%;background:#012851;display:inline-flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:var(--mint);flex-shrink:0;">FR</span>
Franz Raddatz
</span>
</div>
<div class="g-content">
<div class="g-title">Der Sommer in Breslau</div>
<div class="g-excerpt">Oma erzählte oft vom letzten Sommer vor dem Krieg, als die Familie noch vollständig zusammen war und niemand ahnte, was kommen würde…</div>
</div>
</div>
<!-- Row 2: Journey (badge!) -->
<div class="g-row">
<div class="g-meta">
<div class="g-av av-purple">KR</div>
<div class="g-author">Klaus Raddatz</div>
<div class="g-date">15. Mai 2025</div>
<span class="g-chip">
<span style="width:8px;height:8px;border-radius:50%;background:#012851;display:inline-flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:var(--mint);flex-shrink:0;">FR</span>
Franz Raddatz
</span>
<span class="j-badge">REISE</span>
</div>
<div class="g-content">
<div class="g-title">Briefe aus Breslau 19381942</div>
<div class="g-excerpt">Eine Lesereise durch den Briefwechsel zwischen Franz und Emma — von den letzten Friedenssommern bis zum Ende des Krieges.</div>
</div>
</div>
<!-- Row 3: Story -->
<div class="g-row">
<div class="g-meta">
<div class="g-av av-teal">GK</div>
<div class="g-author">Gertrud Koch</div>
<div class="g-date">18. Okt. 2024</div>
<span class="g-chip">
<span style="width:8px;height:8px;border-radius:50%;background:#534AB7;display:inline-flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:#fff;flex-shrink:0;">EM</span>
Emma Müller
</span>
</div>
<div class="g-content">
<div class="g-title">Die Hochzeit im Krieg</div>
<div class="g-excerpt">1943, mitten im Chaos — Emma bestand darauf, dass das Fest stattfand. Ihr Bruder kam auf Fronturlaub, drei Tage nur, aber es reichte…</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Mobile -->
<div class="prev-col">
<span class="bp-lbl">Mobile — 320px</span>
<div class="phone">
<div class="pst"><b>9:41</b><span>&#9679;&#9679;&#9679;</span></div>
<div class="pb">
<div class="m-nav">
<span class="m-logo">ARCHIV</span>
<div class="m-nav-r">
<div class="m-av">MR</div>
<div class="m-ham"><span></span><span></span><span></span></div>
</div>
</div>
<div style="background:#E8E7E2;flex:1;display:flex;flex-direction:column;">
<div style="padding:8px 10px 4px;">
<span style="font-family:Georgia,serif;font-size:13px;color:#012851;">Geschichten</span>
</div>
<div class="m-filters">
<span class="g-pill active" style="font-size:6px;padding:2px 7px;">Alle</span>
<span class="g-pill" style="font-size:6px;padding:2px 7px;">Franz Raddatz</span>
<span class="g-pill" style="font-size:6px;padding:2px 7px;border-style:dashed;">+ Person…</span>
</div>
<div style="background:#fff;flex:1;">
<!-- Story row -->
<div class="m-row">
<div class="m-row-top">
<div class="g-av av-navy" style="width:16px;height:16px;font-size:5.5px;">MR</div>
<span class="m-author-name">Maria Raddatz</span>
<span class="m-date">14. Mrz. 2025</span>
</div>
<div class="m-title">Der Sommer in Breslau</div>
<div class="m-excerpt">Oma erzählte oft vom letzten Sommer vor dem Krieg…</div>
</div>
<!-- Journey row (badge) -->
<div class="m-row">
<div class="m-row-top">
<div class="g-av av-purple" style="width:16px;height:16px;font-size:5.5px;">KR</div>
<span class="m-author-name">Klaus Raddatz</span>
<span class="m-date">15. Mai 2025</span>
</div>
<div style="display:flex;align-items:center;gap:5px;margin-bottom:3px;">
<div class="m-title" style="margin-bottom:0;">Briefe aus Breslau 19381942</div>
<span class="j-badge" style="flex-shrink:0;">REISE</span>
</div>
<div class="m-excerpt">Eine Lesereise durch den Briefwechsel zwischen Franz und Emma…</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>impl-ref — LR-1 Journey-Badge in der Liste</h4>
<table class="at">
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
<tbody>
<tr class="grp"><td colspan="3">Badge</td></tr>
<tr><td>Journey badge</td><td>inline-flex items-center px-1.5 py-px rounded-sm text-[10px] font-bold uppercase tracking-wide bg-orange-50 text-orange-700 border border-orange-200</td><td>nur wenn type === 'JOURNEY'</td></tr>
<tr><td>Position Desktop</td><td>unterhalb Datum-Text und Personenchip in der Metaspalte (g-meta)</td><td>kein extra Abstand nötig — gap-1 der Flex-Spalte reicht</td></tr>
<tr><td>Position Mobile</td><td>inline flex items-center gap-1.5 neben Titel</td><td>Titel + Badge in einem flex-Wrapper; badge shrink-0</td></tr>
<tr><td>aria-label</td><td>aria-label="Lesereise"</td><td>Badge ist span, kein interaktives Element</td></tr>
<tr class="grp"><td colspan="3">Bedingte Logik</td></tr>
<tr><td>Svelte guard</td><td>{#if geschichte.type === 'JOURNEY'}&lt;span …&gt;REISE&lt;/span&gt;{/if}</td><td>kein Badge für STORY</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ═══ SCREEN LR-2: JOURNEY READER ═══ -->
<div class="section">
<div class="section-title">Screens — Journey-Leseansicht</div>
<div class="scr">
<div class="scr-head">
<h3>LR-2 — Journey-Detail /geschichten/[id]</h3>
<span class="scr-id">Issue #752 · LR-2</span>
</div>
<p class="scr-desc">Wenn <code>type === 'JOURNEY'</code> ersetzt die geordnete Briefliste den Prosa-Body. Optional zeigt ein Einleitungsabsatz (<code>body</code>) vor den Items. Jedes Item ist entweder ein Briefeintrag (Kartentitel, Datum, Link) oder ein Interlude-Absatz (orangener linker Rand, kursiv). Die Reihenfolge ergibt sich von oben nach unten — keine Nummern. Briefeinträge können eine optionale Kuratoren-Annotation unter dem Link zeigen.</p>
<p class="scr-var"><strong>Varianten:</strong> Leserin ohne Schreibrecht · BLOG_WRITER (Bearbeiten/Löschen sichtbar — hier gezeigt) · Mobile</p>
<div class="previews">
<!-- Desktop -->
<div class="prev-col" style="width:100%;max-width:1040px;">
<span class="bp-lbl">Desktop — 1040px · BLOG_WRITER-Ansicht</span>
<div class="desk" style="min-height:600px;">
<div class="fa-nav">
<span class="fa-logo">ARCHIV</span>
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
<span class="fa-link">Dokumente</span>
<span class="fa-link">Personen</span>
<span class="fa-link active">Geschichten</span>
<span class="fa-link">Chronik</span>
<div class="fa-nav-r">
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">MR</div>
</div>
</div>
<div style="background:#E8E7E2;flex:1;padding:16px 20px;">
<div class="jr-article">
<div class="jr-back">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M6 2L2 5l4 3" stroke="#6B6A63" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
Zurück zu Geschichten
</div>
<div class="jr-badge">LESEREISE</div>
<div class="jr-title">Briefe aus Breslau 19381942</div>
<div class="jr-metabar">
<div class="g-av av-purple" style="width:20px;height:20px;font-size:6.5px;">KR</div>
<div>
<div style="font-size:7.5px;font-weight:700;color:#1C1C18;line-height:1.2;">Klaus Raddatz</div>
<div style="font-size:6.5px;color:#6B6A63;">zusammengestellt am 15. Mai 2025</div>
</div>
<div class="jr-metabar-r">
<button class="jr-edit-btn">Bearbeiten</button>
<span style="font-size:6.5px;font-weight:600;color:#DC4C3E;">Löschen</span>
</div>
</div>
<!-- Intro -->
<div class="jr-intro">Der Briefwechsel zwischen Franz Raddatz und seiner Schwester Emma umspannt vier Jahre — von den letzten unbeschwerten Sommerwochen 1938 bis zum Kriegsende. Diese Lesereise folgt den Briefen in chronologischer Reihenfolge.</div>
<!-- Item 1: Document, no annotation -->
<div class="jr-item">
<div class="jr-card">
<div class="jr-card-title">Brief vom 12. Juli 1938</div>
<div class="jr-card-meta">12. Juli 1938 &middot; von Franz Raddatz an Emma Müller</div>
<div class="jr-card-link">
<svg width="8" height="8" viewBox="0 0 10 12" fill="none"><rect x="1" y="1" width="8" height="10" rx="1" stroke="#012851" stroke-width="1"/><path d="M3 4h4M3 6.5h4M3 9h2" stroke="#012851" stroke-width=".7" stroke-linecap="round"/></svg>
Brief öffnen
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M4 2l4 3-4 3" stroke="#012851" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
</div>
</div>
<!-- Interlude -->
<div class="jr-interlude">
<div class="jr-interlude-text">Im Sommer 1938 schrieb Franz voller Zuversicht — er hatte kaum eine Ahnung, wie bald sich die Welt um ihn herum verändern würde. Seine Briefe aus dieser Zeit tragen eine Leichtigkeit, die in den späteren Kriegsjahren vollständig verschwindet.</div>
</div>
<!-- Item 2: Document with annotation -->
<div class="jr-item">
<div class="jr-card">
<div class="jr-card-title">Postkarte aus Breslau, August 1938</div>
<div class="jr-card-meta">22. Aug. 1938 &middot; von Franz Raddatz an Emma Müller</div>
<div class="jr-card-link">
<svg width="8" height="8" viewBox="0 0 10 12" fill="none"><rect x="1" y="1" width="8" height="10" rx="1" stroke="#012851" stroke-width="1"/><path d="M3 4h4M3 6.5h4M3 9h2" stroke="#012851" stroke-width=".7" stroke-linecap="round"/></svg>
Brief öffnen
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M4 2l4 3-4 3" stroke="#012851" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<div class="jr-annotation">
<div class="jr-annotation-text">Diese Karte ist ungewöhnlich kurz für Franz — vier Zeilen, fast hastig. Ein Zeichen der aufkommenden Unruhe in den Nachrichten, oder schlicht die Hitze des Augusts?</div>
</div>
</div>
</div>
<!-- Item 3: Document -->
<div class="jr-item">
<div class="jr-card">
<div class="jr-card-title">Brief vom 3. September 1939</div>
<div class="jr-card-meta">3. Sept. 1939 &middot; von Emma Müller an Franz Raddatz</div>
<div class="jr-card-link">
<svg width="8" height="8" viewBox="0 0 10 12" fill="none"><rect x="1" y="1" width="8" height="10" rx="1" stroke="#012851" stroke-width="1"/><path d="M3 4h4M3 6.5h4M3 9h2" stroke="#012851" stroke-width=".7" stroke-linecap="round"/></svg>
Brief öffnen
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M4 2l4 3-4 3" stroke="#012851" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Mobile -->
<div class="prev-col">
<span class="bp-lbl">Mobile — 320px · Leserin</span>
<div class="phone" style="min-height:520px;">
<div class="pst"><b>9:41</b><span>&#9679;&#9679;&#9679;</span></div>
<div class="pb">
<div class="m-nav">
<span class="m-logo">ARCHIV</span>
<div class="m-nav-r">
<div class="m-av">MR</div>
<div class="m-ham"><span></span><span></span><span></span></div>
</div>
</div>
<div style="background:#E8E7E2;flex:1;padding:10px;">
<div class="mjr-article">
<div class="mjr-back">
<svg width="6" height="6" viewBox="0 0 10 10" fill="none"><path d="M6 2L2 5l4 3" stroke="#6B6A63" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
Zurück
</div>
<div class="mjr-badge">LESEREISE</div>
<div class="mjr-title">Briefe aus Breslau 19381942</div>
<div class="mjr-metabar">
<div class="g-av av-purple" style="width:16px;height:16px;font-size:5.5px;flex-shrink:0;">KR</div>
<div>
<div style="font-size:7px;font-weight:700;color:#1C1C18;">Klaus Raddatz</div>
<div style="font-size:6px;color:#6B6A63;">15. Mai 2025</div>
</div>
<div style="margin-left:auto;font-size:12px;color:#6B6A63;">···</div>
</div>
<div style="height:1px;background:#EDECEA;margin-bottom:8px;"></div>
<div class="mjr-intro">Der Briefwechsel zwischen Franz und Emma — von 1938 bis Kriegsende.</div>
<!-- Item 1 -->
<div class="mjr-item">
<div class="mjr-card">
<div class="mjr-card-title">Brief vom 12. Juli 1938</div>
<div class="mjr-card-meta">12. Juli 1938 · Franz → Emma</div>
<div class="mjr-card-link">Brief öffnen →</div>
</div>
</div>
<!-- Interlude -->
<div class="mjr-interlude">
<div class="mjr-interlude-text">Im Sommer 1938 schrieb Franz voller Zuversicht — er hatte kaum eine Ahnung, wie bald sich die Welt um ihn herum verändern würde.</div>
</div>
<!-- Item 2 -->
<div class="mjr-item">
<div class="mjr-card">
<div class="mjr-card-title">Postkarte Aug. 1938</div>
<div class="mjr-card-meta">22. Aug. 1938 · Franz → Emma</div>
<div class="mjr-card-link">Brief öffnen →</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>impl-ref — LR-2 Journey-Leseansicht</h4>
<table class="at">
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
<tbody>
<tr class="grp"><td colspan="3">Seitenstruktur</td></tr>
<tr><td>Bedingte Logik</td><td>{#if geschichte.type === 'JOURNEY'} JourneyReader {:else} StoryReader {/if}</td><td>in +page.svelte von /geschichten/[id]</td></tr>
<tr><td>Artikel-Container</td><td>max-w-3xl mx-auto px-4 py-8</td><td>gleich wie StoryReader</td></tr>
<tr><td>Journey-Badge</td><td>inline-flex px-2 py-px rounded-sm text-[10px] font-bold uppercase tracking-widest bg-orange-50 text-orange-700 border border-orange-200 mb-2</td><td>über dem Titel; nicht für STORY</td></tr>
<tr><td>Titel</td><td>font-serif text-3xl text-ink leading-tight mb-4</td><td>gleich wie Story</td></tr>
<tr><td>Metabar</td><td>flex items-center gap-3 pb-4 border-b border-subtle mb-4</td><td>gleich wie Story</td></tr>
<tr><td>Bearbeiten/Löschen</td><td>nur BLOG_WRITE; auf Mobile im ··· BottomSheet</td><td>gleich wie Story</td></tr>
<tr class="grp"><td colspan="3">Intro-Absatz</td></tr>
<tr><td>Intro (body)</td><td>font-serif text-sm text-ink-2 italic leading-relaxed mb-6 pb-4 border-b border-dashed border-subtle</td><td>nur rendern wenn body nicht leer; kein HTML-Rendering — plaintext</td></tr>
<tr class="grp"><td colspan="3">Dokument-Item</td></tr>
<tr><td>Item-Zeile</td><td>mb-3</td><td>kein flex nötig — Karte ist full-width</td></tr>
<tr><td>Dokumentkarte</td><td>bg-white border border-line rounded-sm p-3</td><td></td></tr>
<tr><td>Brieftitel</td><td>font-serif text-sm text-ink leading-snug mb-0.5</td><td>document.title</td></tr>
<tr><td>Briefmeta</td><td>text-xs text-ink-3 mb-2</td><td>formatDate(document.documentDate) · "von X an Y"</td></tr>
<tr><td>Brief öffnen Link</td><td>inline-flex items-center gap-1 text-xs font-semibold text-ink hover:text-primary</td><td>href="/documents/{item.document.id}"</td></tr>
<tr class="grp"><td colspan="3">Kuratoren-Annotation</td></tr>
<tr><td>Annotation</td><td>mt-3 pl-3 border-l-2 border-mint bg-surface rounded-r-sm py-1.5 pr-2</td><td>nur rendern wenn item.note vorhanden</td></tr>
<tr><td>Annotations-Text</td><td>text-xs italic text-ink-2 leading-relaxed</td><td></td></tr>
<tr class="grp"><td colspan="3">Interlude-Item</td></tr>
<tr><td>Interlude-Block</td><td>pl-3 border-l-2 border-orange-400 bg-orange-50 rounded-r-sm py-2 pr-3 my-4</td><td>item.document === null</td></tr>
<tr><td>Interlude-Text</td><td>text-xs italic text-ink leading-relaxed</td><td>item.note; plaintext</td></tr>
<tr class="grp"><td colspan="3">Mobile</td></tr>
<tr><td>··· Menü</td><td>ml-auto text-ink-3; öffnet BottomSheet mit Bearbeiten + Löschen</td><td>BLOG_WRITE; gleich wie Story</td></tr>
<tr><td>Touch Target (Brief öffnen)</td><td>min-h-[44px] durch padding auf der Karte</td><td>WCAG 2.2 AA</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ═══ LLM IMPLEMENTATION GUIDE ═══ -->
<div class="llm">
<h2>Implementation Guide — Lesereisen Reader</h2>
<h3>Geänderte Views und Routen</h3>
<table>
<thead><tr><th>View</th><th>Route</th><th>Änderung</th></tr></thead>
<tbody>
<tr><td>Neue Geschichte</td><td>/geschichten/new</td><td>Neuer Typauswahl-Schritt als first render; setzt ?type=STORY|JOURNEY</td></tr>
<tr><td>Geschichten-Liste</td><td>/geschichten</td><td>Journey-Badge in GeschichtenCard wenn type === 'JOURNEY'</td></tr>
<tr><td>Geschichte-Detail</td><td>/geschichten/[id]</td><td>Bedingte Verzweigung: JourneyReader | StoryReader</td></tr>
</tbody>
</table>
<h3>Neue Komponenten</h3>
<ul>
<li><code>JourneyReader.svelte</code> — rendert Intro + Items-Liste; Props: <code>geschichte: GeschichteDetail</code></li>
<li><code>JourneyItemCard.svelte</code> — ein Dokument-Item mit optionaler Annotation; Props: <code>item: JourneyItem, position: number</code></li>
<li><code>JourneyInterlude.svelte</code> — ein reiner Text-Interlude; Props: <code>note: string</code></li>
</ul>
<h3>Datenmodell (nach #750)</h3>
<ul>
<li><code>GeschichteType: 'STORY' | 'JOURNEY'</code></li>
<li><code>JourneyItem: { id: UUID, position: number, document: DocumentSummary | null, note: string | null }</code></li>
<li><code>Geschichte.items</code> — geordnete Liste (nach <code>position</code> ASC); für STORY leer</li>
<li><code>Geschichte.body</code> — für JOURNEY der optionale Einleitungstext (plaintext, kein HTML); für STORY der Rich-Text-Body</li>
</ul>
<h3>Typauswahl — Implementierungshinweise</h3>
<ul>
<li>Die Typauswahl ist ein Schritt INNERHALB der <code>/geschichten/new</code>-Route — kein eigener URL, kein <code>goto()</code>. Zustand <code>let selectedType: GeschichteType | null = null</code> in der Komponente.</li>
<li>Erst wenn <code>selectedType !== null</code> ist der „Weiter"-Button aktiviert (<code>disabled={!selectedType}</code>).</li>
<li>Nach Klick auf „Weiter": wenn <code>selectedType === 'JOURNEY'</code><code>goto('/geschichten/new?type=JOURNEY')</code> und zeige den Journey-Editor (aus Issue #753); wenn <code>STORY</code> → bestehender GeschichteEditor (unverändert).</li>
<li>Die Karten verwenden <code>role="radio"</code> und <code>aria-checked</code> für Accessibility. Keyboard: Arrow-Keys wechseln zwischen den Karten, Space/Enter wählt aus.</li>
</ul>
<h3>Journey-Badge — Implementierungshinweise</h3>
<ul>
<li>Badge nur in <code>GeschichtenCard.svelte</code> hinzufügen — keine Änderung an der Listenlogik oder dem API-Aufruf.</li>
<li>Text: „REISE" (Kurzform für die Metaspalte); <code>aria-label="Lesereise"</code> für den Badge-Span.</li>
</ul>
<h3>Journey-Reader — Implementierungshinweise</h3>
<ul>
<li>Items werden bereits geordnet vom Backend geliefert (<code>ORDER BY position ASC</code>). Keine client-seitige Sortierung nötig.</li>
<li>Ein Item ist Interlude wenn <code>item.document === null</code>. In diesem Fall: <code>JourneyInterlude</code>-Komponente rendern.</li>
<li>Der Intro-Absatz (<code>body</code>) wird als Plaintext gerendert — <em>nicht</em> als innerHTML. Im Editor wird es als einfaches Textarea gespeichert, kein HTML.</li>
</ul>
<h3>Berechtigungen</h3>
<ul>
<li>„Bearbeiten" und „Löschen" nur für <code>currentUser.permissions.includes('BLOG_WRITE')</code> — gleich wie Story.</li>
<li>Auf Mobile: Bearbeiten/Löschen im BottomSheet hinter ··· — gleich wie Story.</li>
</ul>
<h3>Barrierefreiheit</h3>
<ul>
<li>Items-Liste: <code>&lt;ol&gt;</code> semantisch für die geordnete Briefliste. Interludes sind <code>&lt;li&gt;</code>-Elemente mit <code>aria-label="Kuratorennotiz"</code>.</li>
<li>„Brief öffnen"-Link: beschreibender Text mit Briefdatum im <code>aria-label</code>, z.B. <code>aria-label="Brief vom 12. Juli 1938 öffnen"</code>.</li>
<li>Touch-Targets: jede Dokumentkarte hat mindestens 44px Höhe durch den Padding der Karte.</li>
<li>Fokusring: <code>focus-visible:ring-2 focus-visible:ring-primary</code> auf allen Links.</li>
</ul>
</div>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,418 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Zeitstrahl — Ereignis-Editor &amp; Brief-Gruppierung · Quick-Action im Dokument · Familienarchiv</title>
<link href="https://fonts.googleapis.com/css2?family=Tinos:ital,wght@0,400;0,700;1,400&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5;font-size:13px}
.page{max-width:1320px;margin:0 auto;padding:48px 32px 120px}
.mh{padding-bottom:24px;border-bottom:3px solid #012851;margin-bottom:48px}
.mh h1{font-size:23px;font-weight:900;color:#012851;letter-spacing:-.4px}
.mh p{font-size:13px;color:#555;max-width:790px;line-height:1.75;margin-top:8px}
.mh .byline{font-size:9px;color:#999;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;margin-top:10px}
.tag-row{display:flex;gap:6px;margin-top:12px;flex-wrap:wrap}
.tg{background:#012851;color:#a1dcd8;padding:2px 8px;border-radius:2px;font-size:8px;font-weight:700;letter-spacing:.8px;text-transform:uppercase}
.tg.mint{background:#a1dcd8;color:#012851}
.tg.slate{background:#607080;color:#e8edf2}
.sh{margin:60px 0 22px;padding-bottom:12px;border-bottom:2px solid #E0DDD6}
.sh h2{font-size:17px;font-weight:900;color:#012851}
.sh p{font-size:12.5px;color:#666;margin-top:5px;max-width:790px;line-height:1.65}
.callout{padding:13px 17px;border-radius:4px;font-size:12px;line-height:1.65;margin-bottom:18px}
.callout.navy{background:rgba(1,40,81,.06);border-left:3px solid #012851;color:#333}
.callout.mint{background:rgba(161,220,216,.18);border-left:3px solid #00c7b1;color:#1f3a3a}
.callout code{font-family:'Courier New',monospace;font-size:11px;background:#fff;padding:1px 4px;border-radius:2px}
.callout strong{font-weight:800;color:#012851}
/* desktop chrome */
.dchrome{background:#F0EFE9;border:1.5px solid #C4C0BA;border-radius:9px;overflow:hidden;box-shadow:0 8px 30px rgba(0,0,0,.12)}
.dbar{height:22px;background:#E2DFD8;border-bottom:1px solid #C4C0BA;display:flex;align-items:center;gap:4px;padding:0 9px}
.ddot{width:7px;height:7px;border-radius:50%;background:#C4BFB8}
.durl{flex:1;height:10px;background:#D2CEC8;border-radius:5px;margin:0 8px;max-width:360px}
.dnav{height:32px;background:#012851;display:flex;align-items:center;gap:13px;padding:0 16px}
.dnav .nlogo{font-family:'Tinos',serif;font-size:10px;color:#fff;font-weight:700}
.dnav .nlink{font-size:7.5px;color:rgba(255,255,255,.5);font-weight:700;text-transform:uppercase;letter-spacing:.4px}
.dnav .nlink.on{color:#fff;border-bottom:2px solid #a1dcd8;padding-bottom:9px}
.dnav .av{margin-left:auto;width:18px;height:18px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:rgba(255,255,255,.55)}
.dcanvas{background:#f0efe9;padding:20px 26px 26px}
/* form atoms (mirror real Tailwind look) */
.lbl{font-size:8.5px;font-weight:800;letter-spacing:1.2px;text-transform:uppercase;color:#6b7280;margin-bottom:6px}
.inp{border:1px solid #e4e2d7;border-radius:4px;background:#fff;padding:8px 11px;font-size:12px;color:#012851}
.inp.title{font-family:'Tinos',serif;font-size:22px;font-weight:700;padding:11px 13px}
.card{border:1px solid #e4e2d7;border-radius:6px;background:#fff;padding:15px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
.card h3{font-size:8.5px;font-weight:800;letter-spacing:1.2px;text-transform:uppercase;color:#6b7280;margin-bottom:3px}
.card .hint{font-size:9.5px;color:#9a9a96;margin-bottom:9px}
.seg{display:inline-flex;border:1px solid #012851;border-radius:5px;overflow:hidden}
.seg span{font-size:10px;font-weight:700;padding:5px 13px;color:#012851}
.seg span.on{background:#012851;color:#fff}
.seg span+span{border-left:1px solid #012851}
.chips{border:1px solid #e4e2d7;border-radius:5px;background:#fff;padding:7px;display:flex;flex-wrap:wrap;gap:6px;align-items:center}
.chip-sel{display:inline-flex;align-items:center;gap:4px;background:#f5f4ef;border-radius:4px;padding:3px 8px;font-size:10px;color:#012851}
.chip-sel .x{color:#012851;opacity:.45;font-size:11px}
.chip-in{flex:1;min-width:90px;font-size:10.5px;color:#9a9a96;padding:3px 4px}
.btn{display:inline-flex;align-items:center;gap:6px;height:38px;padding:0 16px;border-radius:5px;font-size:12px;font-weight:600}
.btn.primary{background:#012851;color:#fff}
.btn.ghost{background:#fff;border:1px solid #e4e2d7;color:#012851}
.btn.danger{background:#fff;border:1px solid #e7c9c4;color:#c0392b}
.tagchip{display:inline-flex;align-items:center;gap:3px;font-size:8px;border-radius:9px;padding:2px 7px;color:#a0522d;background:#f6ece6}
.tagchip i{width:6px;height:6px;border-radius:2px;background:#a0522d;display:inline-block}
/* annotation callouts on mockups */
.anno{display:flex;gap:9px;align-items:flex-start;font-size:11.5px;color:#4a4a46;line-height:1.55;margin-bottom:7px}
.anno .n{flex-shrink:0;width:18px;height:18px;border-radius:50%;background:#012851;color:#a1dcd8;font-size:9px;font-weight:800;display:flex;align-items:center;justify-content:center;margin-top:1px}
.anno b{color:#012851}
.anno code{font-size:10px;background:#F0EFE9;padding:1px 4px;border-radius:2px}
.annogrid{display:grid;grid-template-columns:1fr 1fr;gap:6px 26px;margin-top:14px}
/* dropdown */
.dd{border:1px solid #e4e2d7;border-radius:6px;background:#fff;box-shadow:0 6px 18px rgba(0,0,0,.12);overflow:hidden;margin-top:3px}
.dd .opt{padding:7px 11px;font-size:11px;color:#012851;border-bottom:1px solid #f3f1ea;cursor:pointer}
.dd .opt:hover,.dd .opt.hl{background:#f5f4ef}
.dd .opt:last-child{border-bottom:none}
.dd .opt .d{font-size:8.5px;color:#9a9a96}
/* states grid */
.states{display:grid;grid-template-columns:repeat(2,1fr);gap:18px}
.state{border:1px solid #E0DDD6;border-radius:8px;background:#fff;overflow:hidden}
.state .sh2{background:#F4F2EC;border-bottom:1px solid #E0DDD6;padding:7px 12px;font-size:8.5px;font-weight:800;letter-spacing:.8px;text-transform:uppercase;color:#012851;display:flex;justify-content:space-between}
.state .sb{padding:13px}
.cap{font-size:11px;color:#888;font-style:italic;line-height:1.55;margin-top:8px}
/* impl-ref */
.impl-ref{background:#fff;border:1px solid #E0DDD6;border-radius:7px;overflow:hidden;margin-top:8px}
.impl-ref table{width:100%;border-collapse:collapse}
.impl-ref th{background:#012851;color:#fff;padding:8px 13px;text-align:left;font-size:8px;font-weight:800;letter-spacing:.6px;text-transform:uppercase}
.impl-ref td{padding:8px 13px;border-bottom:1px solid #F0EEE8;vertical-align:top;font-size:11px;color:#444;line-height:1.55}
.impl-ref tr:nth-child(even) td{background:#FAFAF7}
.impl-ref td:first-child{font-weight:700;color:#012851;white-space:nowrap;width:185px}
.impl-ref td code{font-size:9.5px;background:#F0EFE9;padding:1px 4px;border-radius:2px;font-family:'Courier New',monospace;color:#333}
hr{border:none;border-top:2px dashed #C8C4BE;margin:52px 0}
.note{font-size:11px;color:#888;font-style:italic;margin-top:10px;line-height:1.6}
</style>
</head>
<body>
<div class="page">
<!-- ══ MASTHEAD ══ -->
<div class="mh">
<h1>Ereignis-Editor &amp; Brief-Gruppierung · Quick-Action im Dokument</h1>
<p>Wie kuratierte Zeitstrahl-Ereignisse entstehen und wie Briefe gruppiert werden — von zwei Seiten in ein Datenmodell (<code style="font-family:monospace;font-size:12px">TimelineEvent.documents</code>): der <strong>Ereignis-Editor</strong> unter <code style="font-family:monospace;font-size:12px">/zeitstrahl/events/[id]/edit</code> (Kurator baut, verlinkt viele Briefe) und die <strong>Quick-Action im Dokument-Detail</strong> (beim Lesen schnell zuordnen). Beide bauen auf bereits ausgelieferten Komponenten auf.</p>
<div class="tag-row">
<span class="tg">Milestone #14 · Zeitstrahl</span>
<span class="tg mint">Reuse: GeschichteEditor · DocumentMultiSelect · PersonMultiSelect</span>
<span class="tg slate">WRITE_ALL</span>
</div>
<div class="byline">Familienarchiv · 2026-06-08 · Leonie Voss, UX Lead · gegründet auf Code: GeschichteEditor.svelte · DocumentMetadataDrawer.svelte · DocumentMultiSelect.svelte</div>
</div>
<!-- ══ 1 · TWO ENTRY POINTS ══ -->
<div class="sh">
<h2>1 · Zwei Einstiegspunkte, ein Datenmodell</h2>
<p>Manuelle Gruppierung = ein <code>TimelineEvent</code> mit verknüpften Dokumenten. Kuratoren arbeiten in beide Richtungen — wir bauen beide, statt eine zu erzwingen.</p>
</div>
<div class="states" style="grid-template-columns:1fr 1fr">
<div class="callout navy" style="margin:0">
<strong>A · Ereignis-zuerst</strong> — der Kurator baut den Zeitstrahl. <code>/zeitstrahl/events/new · [id]/edit</code> mit Dokument-Mehrfach-Picker = <b>Bulk-Linking</b> vieler Briefe auf einmal. Spiegelt 1:1 den <code>GeschichteEditor</code> (gleiche zwei-Spalten-Form, Sidebar-Picker, Sticky-Save-Bar).
</div>
<div class="callout mint" style="margin:0">
<strong>B · Dokument-zuerst</strong> — beim Lesen eines Briefs. Quick-Action im Dokument-Detail: bestehendes Ereignis wählen <i>oder</i> neu anlegen, verlinkt diesen einen Brief. Spiegelt die bestehende <b>Geschichten-Spalte</b> im Details-Drawer (<code>DocumentMetadataDrawer.svelte</code>).
</div>
</div>
<!-- ══ 2 · EVENT EDITOR ══ -->
<div class="sh">
<h2>2 · Ereignis-Editor — <code style="font-size:14px">/zeitstrahl/events/[id]/edit</code></h2>
<p>Form-Actions-Muster, gegated mit <code>WRITE_ALL</code>. Layout &amp; Verhalten 1:1 vom <code>GeschichteEditor</code> übernommen: Hauptspalte + Sidebar (<code>lg:grid-cols-[2fr_1fr]</code>), Sticky-Save-Bar, <code>beforeNavigate</code>-Warnung bei ungespeicherten Änderungen.</p>
</div>
<div class="dchrome" style="margin-bottom:16px">
<div class="dbar"><div class="ddot"></div><div class="ddot"></div><div class="ddot"></div><div class="durl"></div></div>
<div class="dnav"><span class="nlogo">Familienarchiv</span><span class="nlink">Dokumente</span><span class="nlink">Personen</span><span class="nlink on">Zeitstrahl</span><span class="nlink">Stammbaum</span><span class="av">KR</span></div>
<div class="dcanvas">
<div style="font-size:8px;font-weight:800;letter-spacing:1px;text-transform:uppercase;color:#8a8a86;margin-bottom:10px"> Zurück zum Zeitstrahl</div>
<div style="font-family:'Tinos',serif;font-size:18px;font-weight:700;color:#012851;margin-bottom:16px">Ereignis bearbeiten</div>
<div style="display:grid;grid-template-columns:2fr 1fr;gap:22px">
<!-- MAIN COLUMN -->
<div style="display:flex;flex-direction:column;gap:16px">
<!-- ① title -->
<div>
<div class="inp title" style="width:100%">Briefe von der Front</div>
</div>
<!-- ② type + ③ date/precision -->
<div style="display:flex;gap:22px;flex-wrap:wrap">
<div>
<div class="lbl">② Typ</div>
<div class="seg"><span class="on">Persönlich</span><span>Historisch</span></div>
</div>
<div>
<div class="lbl">③ Datum · Präzision</div>
<div style="display:flex;gap:6px;align-items:center">
<div class="inp" style="width:120px">1915</div>
<div class="inp" style="display:flex;align-items:center;gap:18px;color:#6b7280">Jahr <span style="font-size:8px"></span></div>
</div>
<div style="font-size:9px;color:#9a9a96;margin-top:5px;font-style:italic">Bei „Zeitspanne" erscheint ein zweites End-Datum-Feld. Bei „ca." / „Saison" passt sich nur das Label an.</div>
</div>
</div>
<!-- ④ description -->
<div>
<div class="lbl">④ Beschreibung <span style="color:#bbb;font-weight:600">· optional</span></div>
<div class="inp" style="width:100%;min-height:96px;color:#4a4a46;font-family:'Tinos',serif;line-height:1.6">Karls Feldpost von der Westfront, 1915 — wöchentliche Briefe an Elfriede und den neugeborenen Hans. Eine zusammenhängende Korrespondenz, die hier als Cluster gebündelt wird …</div>
<div style="font-size:9px;color:#9a9a96;margin-top:5px;font-style:italic">Schlichtes Textfeld (kein Rich-Text wie Geschichten) — Ereignisse sind kurze Notizen, keine Langform.</div>
</div>
</div>
<!-- SIDEBAR -->
<aside style="display:flex;flex-direction:column;gap:16px">
<!-- ⑤ linked letters = grouping -->
<div class="card" style="border-color:#a1dcd8;box-shadow:0 2px 10px rgba(161,220,216,.3)">
<h3 style="color:#012851">⑤ Verknüpfte Briefe · 24</h3>
<div class="hint">Diese Briefe bilden den Cluster. <code style="font-size:9px">DocumentMultiSelect</code></div>
<div class="chips">
<span class="chip-sel">✉ Westfront-Brief · Mär 1915 <span class="x">×</span></span>
<span class="chip-sel">✉ Feldpost Verdun · Jul 1915 <span class="x">×</span></span>
<span class="chip-sel">✉ Brief an Elfriede · Sep 1915 <span class="x">×</span></span>
<span class="chip-in">Brief suchen …</span>
</div>
</div>
<!-- ⑥ persons -->
<div class="card">
<h3>⑥ Beteiligte Personen</h3>
<div class="hint">Treibt die Lebensweg-Ansicht &amp; Filter. <code style="font-size:9px">PersonMultiSelect</code></div>
<div class="chips">
<span class="chip-sel">Karl Raddatz <span class="x">×</span></span>
<span class="chip-sel">Elfriede Raddatz <span class="x">×</span></span>
<span class="chip-sel">Hans Raddatz <span class="x">×</span></span>
<span class="chip-in">Person suchen …</span>
</div>
</div>
</aside>
</div>
<!-- ⑦ sticky save bar -->
<div style="margin:18px -26px -26px;border-top:1px solid #e4e2d7;background:#fff;box-shadow:0 -2px 8px rgba(0,0,0,.05);padding:13px 26px;display:flex;align-items:center;justify-content:space-between">
<span style="font-size:10px;color:#9a9a96">Änderungen werden erst beim Speichern übernommen.</span>
<div style="display:flex;gap:8px">
<span class="btn danger">Löschen</span>
<span class="btn ghost">Abbrechen</span>
<span class="btn primary">Speichern</span>
</div>
</div>
</div>
</div>
<div class="annogrid">
<div class="anno"><span class="n"></span><span><b>Titel</b> — großes Serifen-Feld, wie der Geschichten-Titel. Pflichtfeld (Validierung bei Blur).</span></div>
<div class="anno"><span class="n"></span><span><b>Typ</b><code>PERSONAL</code> / <code>HISTORICAL</code> Segmented-Control. Steuert Rendering (Mint-Pille vs. Welt-Band).</span></div>
<div class="anno"><span class="n"></span><span><b>Datum + Präzision</b> — geteilte <code>DatePrecisionInput</code> (gleiche Logik wie Dokument-Datum, <code>metaDatePrecision</code>). „Zeitspanne" blendet End-Datum ein.</span></div>
<div class="anno"><span class="n"></span><span><b>Beschreibung</b> — optionales Textfeld (<code>TEXT</code>), bewusst schlicht.</span></div>
<div class="anno"><span class="n"></span><span><b>Verknüpfte Briefe</b><b>hier wird gruppiert.</b> Wiederverwendung von <code>DocumentMultiSelect</code> (Typeahead, Chips, Hidden-Inputs).</span></div>
<div class="anno"><span class="n"></span><span><b>Beteiligte Personen</b><code>PersonMultiSelect</code>. Bestimmt, in welchem „Lebensweg" das Ereignis auftaucht.</span></div>
<div class="anno"><span class="n"></span><span><b>Sticky-Save-Bar</b> — Speichern primär, Abbrechen sekundär, Löschen nur im Edit-Modus (mit Bestätigung).</span></div>
<div class="anno"><span class="n"></span><span><b>/new</b> — leeres Formular. Mit <code>?documentId=…</code> ist Feld ⑤ vorbefüllt (aus der Quick-Action, §4-D).</span></div>
</div>
<!-- ══ 3 · GROUPING / DOCUMENT PICKER ══ -->
<div class="sh">
<h2>3 · Brief-Gruppierung im Editor — der Dokument-Picker</h2>
<p>Feld ⑤ ist der unveränderte <code>DocumentMultiSelect</code>: Tippen sucht über <code>/api/documents/search?q=…</code> (debounced 300&nbsp;ms), Treffer mit ehrlichem Datums-Label, bereits gewählte werden gefiltert. Jeder Klick fügt einen Brief zum Cluster.</p>
</div>
<div class="states">
<div class="state">
<div class="sh2"><span>Suche aktiv — Dropdown</span><span style="color:#9a9a96">DocumentMultiSelect</span></div>
<div class="sb">
<div class="chips" style="margin-bottom:0">
<span class="chip-sel">✉ Westfront-Brief · Mär 1915 <span class="x">×</span></span>
<span class="chip-sel">✉ Feldpost Verdun · Jul 1915 <span class="x">×</span></span>
<span class="chip-in" style="color:#012851">Verdun▏</span>
</div>
<div class="dd">
<div class="opt hl">Feldpost aus Verdun <span class="d">· Brief · Juli 1915</span></div>
<div class="opt">Brief aus dem Verdun-Lazarett <span class="d">· Brief · August 1916</span></div>
<div class="opt">Rückkehr aus Verdun <span class="d">· Brief · ca. 1917</span></div>
</div>
<div class="cap">Label = <code style="font-size:10px">title · formatDocumentDate(precision)</code>. Bereits verknüpfte Briefe erscheinen nicht in den Treffern (Dedup).</div>
</div>
</div>
<div class="state">
<div class="sh2"><span>Inline „+ Ereignis" am Jahres-Band</span><span style="color:#9a9a96">Zeitstrahl</span></div>
<div class="sb">
<div style="position:relative;padding-left:20px">
<div style="position:absolute;left:6px;top:2px;bottom:2px;width:2px;background:linear-gradient(#a1dcd8,#012851)"></div>
<div style="font-family:'Tinos',serif;font-size:13px;font-weight:700;color:#012851;margin-bottom:8px">1915</div>
<div style="background:#FAF9F5;border:1px solid #eeede8;border-radius:4px;padding:7px 9px;margin-bottom:8px"><div style="font-size:9.5px;font-weight:700;color:#012851">✉ 24 Briefe</div><div style="font-size:8px;color:#9a9a96">Monats-Dichte ▾</div></div>
<button style="display:inline-flex;align-items:center;gap:5px;border:1px dashed #a1dcd8;background:#fff;border-radius:6px;padding:5px 11px;font-size:10px;font-weight:600;color:#012851"> Ereignis aus diesem Jahr anlegen</button>
</div>
<div class="cap">Kuratoren können auch direkt im Zeitstrahl ein Ereignis anlegen — öffnet denselben Editor, Jahr &amp; Briefe des Bandes vorbefüllt.</div>
</div>
</div>
</div>
<hr>
<!-- ══ 4 · QUICK ACTION IN DOCUMENT DETAIL ══ -->
<div class="sh">
<h2>4 · Quick-Action im Dokument-Detail — wo sie lebt</h2>
<p>Die Dokument-Detailseite ist ein <b>vollflächiger Viewer ohne Sidebar</b> (<code>fixed inset</code>). Aktions-Flächen gibt es nur zwei: die <code>DocumentTopBar</code> und den aufklappbaren <b>Details-Drawer</b>. Die Quick-Action lebt an beiden — primär als <b>„Zeitstrahl"-Spalte im Drawer</b> (spiegelt die Geschichten-Spalte), plus ein <b>Top-Bar-Button</b> für den Ein-Klick-Weg.</p>
</div>
<div class="callout navy"><strong>Warum der Details-Drawer der richtige Ort ist:</strong> Er zeigt heute schon, <i>wozu ein Brief gehört</i> — Personen, Schlagwörter und <b>Geschichten</b> (mit „Zuordnen"-Aktion, gegated über <code>canBlogWrite</code>, <code>DocumentMetadataDrawer.svelte:210</code>). Zeitstrahl-Ereignisse sind strukturell identisch („dieser Brief gehört zu diesen Ereignissen") und bekommen daher eine gleichwertige vierte/fünfte Spalte. Konsistent &amp; auffindbar dort, wo Nutzer ohnehin „Zugehörigkeit" suchen.</div>
<div class="dchrome" style="margin-bottom:16px">
<div class="dbar"><div class="ddot"></div><div class="ddot"></div><div class="ddot"></div><div class="durl"></div></div>
<!-- document topbar -->
<div style="background:#fff;border-bottom:1px solid #e4e2d7;box-shadow:0 1px 3px rgba(0,0,0,.05);display:flex;align-items:center;height:54px">
<div style="width:3px;height:100%;background:#012851"></div>
<div style="width:34px;display:flex;justify-content:center;color:#6b7280;font-size:14px"></div>
<div style="width:1px;height:22px;background:#e4e2d7;margin:0 6px"></div>
<div style="flex:0 1 auto;min-width:0;padding-right:10px">
<div style="font-family:'Tinos',serif;font-size:13px;font-weight:700;color:#012851;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">Brief über die Lage an der Westfront</div>
<div style="font-size:8.5px;color:#6b7280">März 1915</div>
</div>
<div style="display:flex;gap:3px;margin-left:6px"><span style="width:20px;height:20px;border-radius:50%;background:#012851;color:#fff;font-size:7px;display:flex;align-items:center;justify-content:center">KR</span><span style="font-size:9px;color:#9a9a96;align-self:center"></span><span style="width:20px;height:20px;border-radius:50%;background:#5a8a6a;color:#fff;font-size:7px;display:flex;align-items:center;justify-content:center">ER</span></div>
<div style="margin-left:auto;display:flex;align-items:center;gap:7px;padding-right:14px">
<!-- Details toggle (active) -->
<span style="display:inline-flex;align-items:center;gap:5px;background:#012851;color:#fff;border-radius:5px;font-size:10px;font-weight:600;padding:6px 11px">Details ▴</span>
<div style="width:1px;height:22px;background:#e4e2d7"></div>
<!-- action buttons -->
<span style="display:inline-flex;align-items:center;gap:4px;border:1px solid #e4e2d7;border-radius:5px;font-size:10px;font-weight:600;color:#012851;padding:6px 11px">✎ Transkribieren</span>
<!-- NEW: Zeitstrahl quick button -->
<span style="display:inline-flex;align-items:center;gap:4px;background:#a1dcd8;color:#012851;border-radius:5px;font-size:10px;font-weight:700;padding:6px 11px">⊕ Zeitstrahl</span>
<span style="display:inline-flex;align-items:center;gap:4px;border:1px solid #e4e2d7;border-radius:5px;font-size:10px;color:#012851;padding:6px 9px"></span>
</div>
</div>
<!-- metadata drawer (opened) -->
<div style="background:#fff;border-bottom:1px solid #e4e2d7;padding:20px 24px">
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:24px">
<!-- Details -->
<div>
<div class="lbl">Details</div>
<div style="font-size:8px;font-weight:600;color:#9a9a96;margin-bottom:1px">Datum</div><div style="font-family:'Tinos',serif;font-size:12px;color:#012851;margin-bottom:8px">März 1915</div>
<div style="font-size:8px;font-weight:600;color:#9a9a96;margin-bottom:1px">Ort</div><div style="font-family:'Tinos',serif;font-size:12px;color:#012851;margin-bottom:8px">Westfront</div>
<div style="font-size:8px;font-weight:600;color:#9a9a96;margin-bottom:1px">Status</div><div style="font-family:'Tinos',serif;font-size:12px;color:#012851">Transkribiert</div>
</div>
<!-- Personen -->
<div>
<div class="lbl">Personen</div>
<div style="display:flex;align-items:center;gap:7px;margin-bottom:6px"><span style="width:26px;height:26px;border-radius:50%;background:#012851;color:#fff;font-size:8px;display:flex;align-items:center;justify-content:center">KR</span><span style="font-family:'Tinos',serif;font-size:12px;color:#012851">Karl Raddatz</span></div>
<div style="display:flex;align-items:center;gap:7px"><span style="width:26px;height:26px;border-radius:50%;background:#5a8a6a;color:#fff;font-size:8px;display:flex;align-items:center;justify-content:center">ER</span><span style="font-family:'Tinos',serif;font-size:12px;color:#012851">Elfriede Raddatz</span></div>
</div>
<!-- Schlagwörter -->
<div>
<div class="lbl">Schlagwörter</div>
<div style="display:flex;flex-wrap:wrap;gap:5px"><span style="background:#f5f4ef;border-radius:3px;font-size:8px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;color:#012851;padding:3px 7px">Krieg</span><span style="background:#f5f4ef;border-radius:3px;font-size:8px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;color:#012851;padding:3px 7px">Briefe von der Front</span></div>
</div>
<!-- NEW: Zeitstrahl column -->
<div style="border-left:2px solid #eef6f5;padding-left:18px;margin-left:-6px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:9px">
<span class="lbl" style="margin:0;color:#012851">Zeitstrahl</span>
<span style="font-size:9px;font-weight:600;color:#6b7280">+ Zuordnen</span>
</div>
<!-- linked event -->
<div style="border:1px solid #e4e2d7;border-radius:5px;padding:7px 9px;margin-bottom:8px">
<div style="display:flex;align-items:center;justify-content:space-between"><span style="font-family:'Tinos',serif;font-size:11px;font-weight:700;color:#012851">Briefe von der Front</span><span style="color:#9a9a96;font-size:11px">×</span></div>
<div style="font-size:8px;color:#9a9a96;margin-top:1px">1915 · 24 Briefe · persönlich</div>
<span class="tagchip" style="margin-top:5px"><i></i>Krieg</span>
</div>
<!-- quick add row -->
<div style="display:flex;gap:6px">
<span style="flex:1;border:1px solid #e4e2d7;border-radius:4px;font-size:9px;color:#9a9a96;padding:5px 8px">Ereignis suchen …</span>
<span style="background:#012851;color:#fff;border-radius:4px;font-size:9px;font-weight:600;padding:5px 8px;white-space:nowrap"> Neu</span>
</div>
</div>
</div>
</div>
<div style="height:120px;background:repeating-linear-gradient(45deg,#ececec,#ececec 8px,#e4e4e4 8px,#e4e4e4 16px);display:flex;align-items:center;justify-content:center;color:#aaa;font-size:10px">↓ PDF-Viewer (Brief-Scan) …</div>
</div>
<div class="annogrid">
<div class="anno"><span class="n">A</span><span><b>Top-Bar-Button „⊕ Zeitstrahl"</b> — Mint-Akzent im Aktions-Cluster (<code>DocumentTopBarActions</code>). Öffnet ein kleines Popover zum Ein-Klick-Zuordnen, ohne den Drawer zu öffnen. Im Mobile-Menü als Eintrag.</span></div>
<div class="anno"><span class="n">B</span><span><b>„Zeitstrahl"-Spalte im Details-Drawer</b> — neue Spalte neben Geschichten. Zeigt verknüpfte Ereignisse (Titel · Datum · Tag-Chip), Unlink über <code>×</code>, plus Quick-Add-Zeile. Nur sichtbar/aktiv bei <code>canWrite</code>.</span></div>
<div class="anno"><span class="n">C</span><span><b>Quick-Add-Zeile</b> — Typeahead „Ereignis suchen …" (sofortiges Verlinken, keine Navigation) + <b>„+ Neu"</b>.</span></div>
<div class="anno"><span class="n">D</span><span><b>„+ Neu"</b><code>/zeitstrahl/events/new?documentId={id}</code> — öffnet den Editor (§2) mit diesem Brief in Feld ⑤ vorbefüllt. Spiegelt <code>/geschichten/new?documentId=</code>.</span></div>
</div>
<!-- ══ 5 · QUICK-ADD STATES ══ -->
<div class="sh"><h2>5 · Quick-Action — Zustände</h2><p>Der Typeahead in der Zeitstrahl-Spalte (oder im Top-Bar-Popover). Gleiches Muster wie <code>DocumentMultiSelect</code>, nur sucht es Ereignisse statt Dokumente.</p></div>
<div class="states" style="grid-template-columns:repeat(2,1fr)">
<div class="state">
<div class="sh2"><span>A · Nicht zugeordnet</span></div>
<div class="sb">
<div style="font-size:10px;color:#9a9a96;font-style:italic;margin-bottom:9px">Noch keinem Ereignis zugeordnet.</div>
<div style="display:flex;gap:6px"><span style="flex:1;border:1px solid #e4e2d7;border-radius:4px;font-size:10px;color:#9a9a96;padding:6px 9px">Ereignis suchen …</span><span class="btn primary" style="height:30px;font-size:10px;padding:0 11px"> Neu</span></div>
</div>
</div>
<div class="state">
<div class="sh2"><span>B · Suche — Treffer</span></div>
<div class="sb">
<div style="border:1px solid #012851;border-radius:4px;font-size:10px;color:#012851;padding:6px 9px;margin-bottom:0">Front▏</div>
<div class="dd">
<div class="opt hl">Briefe von der Front <span class="d">· 1915 · 24 Briefe</span></div>
<div class="opt">Kriegsausbruch <span class="d">· 1914 · 6 Briefe</span></div>
<div class="opt" style="color:#012851;font-weight:600"> „Front" als neues Ereignis anlegen</div>
</div>
</div>
</div>
<div class="state">
<div class="sh2"><span>C · Zugeordnet</span></div>
<div class="sb">
<div style="border:1px solid #e4e2d7;border-radius:5px;padding:7px 9px"><div style="display:flex;align-items:center;justify-content:space-between"><span style="font-family:'Tinos',serif;font-size:11px;font-weight:700;color:#012851">Briefe von der Front</span><span style="font-size:9px;color:#2e7d57">✓ verknüpft <span style="color:#9a9a96">×</span></span></div><span class="tagchip" style="margin-top:5px"><i></i>Krieg</span></div>
<div class="cap">Sofortiges Verlinken (POST). Toast „Zum Ereignis hinzugefügt", <code style="font-size:10px">aria-live</code>. Unlink über <code>×</code> (DELETE).</div>
</div>
</div>
<div class="state">
<div class="sh2"><span>D · Mehrfach zugeordnet</span></div>
<div class="sb">
<div style="display:flex;flex-direction:column;gap:5px">
<div style="border:1px solid #e4e2d7;border-radius:5px;padding:5px 9px;display:flex;justify-content:space-between"><span style="font-family:'Tinos',serif;font-size:10.5px;color:#012851">Briefe von der Front</span><span style="font-size:9px;color:#9a9a96">×</span></div>
<div style="border:1px solid #e4e2d7;border-radius:5px;padding:5px 9px;display:flex;justify-content:space-between"><span style="font-family:'Tinos',serif;font-size:10.5px;color:#012851">Weihnachten 1915</span><span style="font-size:9px;color:#9a9a96">×</span></div>
</div>
<div class="cap">Ein Brief darf zu mehreren Ereignissen gehören (ManyToMany) — alle werden gelistet.</div>
</div>
</div>
</div>
<!-- ══ 6 · TOKENS ══ -->
<div class="sh"><h2>6 · Wiederverwendete Bausteine &amp; Tokens</h2></div>
<div class="callout mint"><strong>Maximal wiederverwenden:</strong> <code>DocumentMultiSelect</code> (Brief-Gruppierung, unverändert) · <code>PersonMultiSelect</code> (Beteiligte) · <code>GeschichteEditor</code>-Layout (zwei Spalten, Sticky-Save, <code>beforeNavigate</code>) · <code>DocumentMetadataDrawer</code>-Spaltenmuster (Quick-Action) · <code>useUnsavedWarning</code> · <code>formatDocumentDate</code> / <code>DatePrecision</code>. Brand-Tokens wie im Zeitstrahl-Spec: Navy <code>#012851</code>, Mint <code>#a1dcd8</code>, Linie <code>#e4e2d7</code>, ink-3 <code>#6b7280</code>, danger <code>#c0392b</code>; Serifen-Titel (Tinos), Sans-Chrome (Montserrat).</div>
<!-- ══ 7 · IMPL-REF ══ -->
<div class="sh"><h2>7 · Implementierungs-Referenz &amp; Barrierefreiheit</h2></div>
<div class="impl-ref">
<table>
<tr><th>Baustein</th><th>Datei / Endpoint</th><th>Verantwortung</th></tr>
<tr><td>Editor-Route (neu)</td><td><code>/zeitstrahl/events/new · [id]/edit</code></td><td><code>+page.server.ts</code> (Form-Actions, <code>WRITE_ALL</code>) + <code>+page.svelte</code>; <code>?documentId=</code> vorbefüllt Feld ⑤</td></tr>
<tr><td>Editor-Komponente (neu)</td><td><code>TimelineEventEditor.svelte</code></td><td>Spiegelt <code>GeschichteEditor</code>: Titel, Typ, Datum+Präzision, Beschreibung; Sidebar-Picker; Sticky-Save; <code>beforeNavigate</code></td></tr>
<tr><td>Brief-Gruppierung (reuse)</td><td><code>DocumentMultiSelect.svelte</code></td><td>Unverändert — Typeahead <code>/api/documents/search</code>, Chips, Hidden-Inputs <code>documentIds</code></td></tr>
<tr><td>Personen (reuse)</td><td><code>PersonMultiSelect.svelte</code></td><td>Unverändert — Beteiligte Personen</td></tr>
<tr><td>Datum + Präzision</td><td><code>DatePrecisionInput</code> (geteilt)</td><td>Wie Dokument-Datum (<code>metaDatePrecision</code>); „Zeitspanne" → End-Datum; <code>formatDocumentDate</code> fürs Label</td></tr>
<tr><td>Quick-Action-Spalte (neu)</td><td><code>DocumentTimelineColumn.svelte</code></td><td>Im <code>DocumentMetadataDrawer</code> neben Geschichten; verknüpfte Ereignisse + Quick-Add; nur bei <code>canWrite</code></td></tr>
<tr><td>Quick-Add-Picker (neu)</td><td><code>DocumentTimelineEventPicker.svelte</code></td><td>Ereignis-Typeahead; sofort verlinken oder <code>?documentId=</code> zum Editor; auch im Top-Bar-Popover</td></tr>
<tr><td>Top-Bar-Button (neu)</td><td><code>DocumentTopBarActions</code> · <code>DocumentMobileMenu</code></td><td>„⊕ Zeitstrahl"-Button (canWrite); öffnet Quick-Add-Popover</td></tr>
<tr><td>Backend — CRUD</td><td><code>POST · PUT · DELETE /api/timeline/events</code></td><td><code>TimelineEventController</code>, <code>WRITE_ALL</code>; <code>TimelineEventRequest</code> mit <code>documentIds</code> / <code>personIds</code></td></tr>
<tr><td>Backend — Link/Unlink</td><td><code>PUT /api/timeline/events/{id}</code></td><td>Verlinken/Lösen läuft über das Event-Update (<code>documents</code>-Set); kein neuer ErrorCode nötig</td></tr>
<tr><td>Barrierefreiheit</td><td></td><td>Picker-Dropdowns Tastatur-navigierbar (↑↓↵), <code>aria-live</code> für „verknüpft/gelöst"; 44px-Ziele; sichtbarer Fokus-Ring; Löschen/Unlink mit Bestätigung</td></tr>
</table>
</div>
<div class="note">Offene Designentscheidung: Soll der Top-Bar-Button (A) MVP sein oder reicht zunächst die Drawer-Spalte (B)? Empfehlung: <b>B als MVP</b> (spiegelt Geschichten exakt, geringster Aufwand), A als schneller Nachzug.</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,391 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Globaler Zeitstrahl — Finale Spezifikation (Konzept A) · Milestone #14 · Familienarchiv</title>
<link href="https://fonts.googleapis.com/css2?family=Tinos:ital,wght@0,400;0,700;1,400&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5;font-size:13px}
.page{max-width:1320px;margin:0 auto;padding:48px 32px 120px}
/* Masthead */
.mh{padding-bottom:24px;border-bottom:3px solid #012851;margin-bottom:48px}
.mh h1{font-size:23px;font-weight:900;color:#012851;letter-spacing:-.4px}
.mh p{font-size:13px;color:#555;max-width:780px;line-height:1.75;margin-top:8px}
.mh .byline{font-size:9px;color:#999;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;margin-top:10px}
.tag-row{display:flex;gap:6px;margin-top:12px;flex-wrap:wrap}
.tg{background:#012851;color:#a1dcd8;padding:2px 8px;border-radius:2px;font-size:8px;font-weight:700;letter-spacing:.8px;text-transform:uppercase}
.tg.mint{background:#a1dcd8;color:#012851}
.tg.slate{background:#607080;color:#e8edf2}
.sh{margin:60px 0 24px;padding-bottom:12px;border-bottom:2px solid #E0DDD6}
.sh h2{font-size:17px;font-weight:900;color:#012851}
.sh p{font-size:12.5px;color:#666;margin-top:5px;max-width:780px;line-height:1.65}
.callout{padding:13px 17px;border-radius:4px;font-size:12px;line-height:1.65;margin-bottom:18px}
.callout.navy{background:rgba(1,40,81,.06);border-left:3px solid #012851;color:#333}
.callout.mint{background:rgba(161,220,216,.18);border-left:3px solid #00c7b1;color:#1f3a3a}
.callout strong{font-weight:800;color:#012851}
/* legend */
.legend{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
.lg{background:#fff;border:1px solid #E0DDD6;border-radius:7px;padding:12px 14px;display:flex;gap:11px}
.lg .ico{flex-shrink:0;width:30px;height:30px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:14px}
.lg .ttl{font-size:10.5px;font-weight:800;color:#012851;margin-bottom:2px}
.lg .body{font-size:9.5px;color:#5a5a56;line-height:1.5}
.lg .body code{font-size:8.5px;background:#F0EFE9;padding:1px 3px;border-radius:2px;color:#444}
/* precision table */
.rules{background:#fff;border:1px solid #E0DDD6;border-radius:7px;overflow:hidden;margin-top:8px}
.rules table{width:100%;border-collapse:collapse}
.rules th{background:#F4F2EC;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;padding:8px 12px;text-align:left;border-bottom:1px solid #E0DDD6}
.rules td{font-size:11px;color:#444;padding:7px 12px;border-bottom:1px solid #F0EEE8;vertical-align:top;line-height:1.5}
.rules tr:last-child td{border-bottom:none}
.rules td:first-child{font-weight:700;color:#012851;width:110px}
.rules td code{font-size:10px;background:#F0EFE9;padding:1px 5px;border-radius:2px;color:#444}
.rules .ex{font-family:'Tinos',serif;font-size:13px;color:#012851}
/* ── Timeline atoms (Konzept A) ── */
.tl-canvas{background:#f0efe9;border:1.5px solid #E0DDD6;border-radius:10px;padding:24px 26px 30px}
.dh{font-family:'Tinos',serif;font-size:20px;font-weight:700;color:#012851}
.dh-sub{font-size:9.5px;color:#7a7a76;margin-bottom:20px}
/* desktop centered axis */
.axis{position:relative;max-width:780px;margin:0 auto;padding:4px 0}
.axis::before{content:"";position:absolute;left:50%;top:0;bottom:0;width:2.5px;background:linear-gradient(#a1dcd8,#012851,#607080);transform:translateX(-50%)}
.ybadge{text-align:center;position:relative;margin:4px 0 14px}
.ybadge span{background:#012851;color:#fff;font-family:'Tinos',serif;font-size:13px;font-weight:700;padding:2px 15px;border-radius:12px;position:relative;z-index:2}
.pill{text-align:center;position:relative;margin-bottom:15px}
.pill .inner{display:inline-flex;align-items:center;gap:9px;background:#fff;border:1.5px solid #012851;border-radius:22px;padding:5px 16px 5px 5px;position:relative;z-index:2;box-shadow:0 2px 7px rgba(1,40,81,.12)}
.pill.curated .inner{border-color:#a1dcd8;border-width:2px}
.pill .gly{width:28px;height:28px;border-radius:50%;background:#012851;color:#a1dcd8;font-size:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0}
.pill.curated .gly{background:#a1dcd8;color:#012851}
.pill .tx{text-align:left}
.pill .tx .t{font-family:'Tinos',serif;font-size:12px;font-weight:700;color:#012851;display:block;line-height:1.15}
.pill .tx .s{font-size:8.5px;color:#8a8a86}
.wband{position:relative;margin:0 -26px 15px;padding:7px 26px;background:#EEEBE2;border-top:1px solid #ddd8cc;border-bottom:1px solid #ddd8cc;text-align:center}
.wband .t{font-family:'Tinos',serif;font-style:italic;font-size:12px;color:#5a6776}
.wband .s{font-size:8.5px;color:#8a8a86;margin-left:8px}
.lrow{display:flex;align-items:flex-start;margin-bottom:12px}
.lrow .half{flex:1}
.lrow .dot{width:13px;height:13px;border-radius:50%;background:#fff;border:2.5px solid #a1dcd8;margin-top:9px;flex-shrink:0;z-index:2;position:relative}
.lrow .a{padding-right:26px;text-align:right}
.lrow .b{padding-left:26px;text-align:left}
.lcard{display:inline-block;text-align:left;background:#fff;border:1px solid #e4e2d7;border-radius:5px;padding:8px 11px;box-shadow:0 1px 3px rgba(0,0,0,.05);max-width:300px}
.lcard.ev{border-left:3px solid #a1dcd8}
.lcard .t{font-size:11px;font-weight:700;color:#1A1A1A}
.lcard.ev .t{font-family:'Tinos',serif}
.lcard .m{font-size:8.5px;color:#9a9a96;margin-top:1px}
.chip{display:inline-flex;align-items:center;gap:3px;font-size:7.5px;border-radius:9px;padding:2px 7px;margin-top:5px}
.chip i{width:6px;height:6px;border-radius:2px;display:inline-block;flex-shrink:0}
.chip.krieg{color:#a0522d;background:#f6ece6}.chip.krieg i{background:#a0522d}
.chip.weih{color:#c17a00;background:#fbf3e3}.chip.weih i{background:#c17a00}
.chip.fam{color:#5a8a6a;background:#eaf1ec}.chip.fam i{background:#5a8a6a}
/* dense aggregate strip */
.strip{max-width:440px;margin:0 auto 15px;position:relative;z-index:2;background:#fff;border:1px solid #e4e2d7;border-radius:6px;box-shadow:0 1px 3px rgba(0,0,0,.05);padding:9px 13px}
.strip .hd{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}
.strip .ct{font-size:10.5px;font-weight:700;color:#012851}
.strip .ex{font-size:8px;color:#8a8a86}
.spark{display:flex;align-items:flex-end;gap:1.5px;height:30px}
.spark div{flex:1;background:#a1dcd8;border-radius:1px;min-height:1px}
.strip .axl{display:flex;justify-content:space-between;margin-top:3px}
.strip .axl span{font-size:6px;color:#bbb}
/* compressed gap */
.gap{max-width:440px;margin:0 auto 15px;position:relative;z-index:2;display:flex;align-items:center;gap:9px;padding:5px 14px;border:1px dashed #cfccc4;border-radius:18px;background:#f0efe9;color:#9a958c;font-size:9px;font-style:italic}
.gap .ln{flex:1;height:1px;background:#ddd8cc}
.gap b{color:#7a756c;font-style:normal;font-family:'Tinos',serif;font-size:10px}
/* undated bucket */
.undated{max-width:540px;margin:20px auto 0;background:#fff;border:1px dashed #c4c0ba;border-radius:6px;padding:12px 15px}
.undated .h{font-family:'Tinos',serif;font-size:12px;font-weight:700;color:#7a756c;margin-bottom:6px}
/* case tag floating */
.casetag{display:inline-block;background:#012851;color:#a1dcd8;font-size:7px;font-weight:800;letter-spacing:.6px;text-transform:uppercase;padding:2px 7px;border-radius:3px;margin-bottom:6px}
/* narrow (phone / rail) column */
.col3{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}
.modehd{font-size:9px;font-weight:800;letter-spacing:1px;text-transform:uppercase;color:#012851;margin-bottom:4px;display:flex;align-items:center;gap:6px}
.modehd .seg{background:#012851;color:#fff;font-size:7px;padding:2px 7px;border-radius:4px}
.modesub{font-size:9.5px;color:#888;font-style:italic;margin-bottom:9px;line-height:1.45;min-height:42px}
.nf{background:#fff;border:1px solid #e4e2d7;border-radius:6px;padding:14px}
.nsp{position:relative;padding-left:21px}
.nsp::before{content:"";position:absolute;left:7px;top:4px;bottom:4px;width:2px;background:linear-gradient(#a1dcd8,#012851,#607080)}
.nyr{font-family:'Tinos',serif;font-size:12px;font-weight:700;color:#012851;margin:2px 0 7px;position:relative}
.nyr::before{content:"";position:absolute;left:-14px;top:4px;width:8px;height:8px;border-radius:50%;background:#012851;border:2px solid #fff;box-shadow:0 0 0 1.5px #012851}
.nnode{position:relative;margin-bottom:9px}
.nnode .g{position:absolute;left:-18px;top:0;width:14px;height:14px;border-radius:50%;background:#012851;border:2px solid #fff;box-shadow:0 0 0 1.5px #012851;color:#a1dcd8;font-size:8px;display:flex;align-items:center;justify-content:center}
.nnode .lt{font-family:'Tinos',serif;font-size:10.5px;font-weight:700;color:#012851;line-height:1.2}
.nnode .lm{font-size:7.5px;color:#8a8a86}
.nwb{position:relative;margin:0 0 9px -9px;padding:5px 8px;background:#EEEBE2;border-left:2px solid #607080;border-radius:0 3px 3px 0}
.nwb .t{font-family:'Tinos',serif;font-style:italic;font-size:9.5px;color:#5a6776}
.nwb .s{font-size:7px;color:#8a8a86}
.nletter{position:relative;margin-bottom:7px}
.nletter .d{position:absolute;left:-15px;top:3px;width:6px;height:6px;border-radius:50%;background:#fff;border:1.5px solid #a1dcd8}
.ncard{background:#FAF9F5;border:1px solid #eeede8;border-radius:4px;padding:5px 8px}
.ncard .t{font-size:9px;font-weight:700;color:#1A1A1A}
.ncard .m{font-size:7px;color:#9a9a96}
.nstrip{background:#FAF9F5;border:1px solid #eeede8;border-radius:4px;padding:6px 8px;margin-bottom:7px}
.nbucket{border:1px solid #e4e2d7;border-radius:4px;padding:5px 8px;margin-bottom:5px;display:flex;align-items:center;justify-content:space-between}
/* impl-ref */
.impl-ref{background:#fff;border:1px solid #E0DDD6;border-radius:7px;overflow:hidden;margin-top:8px}
.impl-ref table{width:100%;border-collapse:collapse}
.impl-ref th{background:#012851;color:#fff;padding:8px 13px;text-align:left;font-size:8px;font-weight:800;letter-spacing:.6px;text-transform:uppercase}
.impl-ref td{padding:8px 13px;border-bottom:1px solid #F0EEE8;vertical-align:top;font-size:11px;color:#444;line-height:1.55}
.impl-ref tr:nth-child(even) td{background:#FAFAF7}
.impl-ref td:first-child{font-weight:700;color:#012851;white-space:nowrap;width:165px}
.impl-ref td code{font-size:9.5px;background:#F0EFE9;padding:1px 4px;border-radius:2px;font-family:'Courier New',monospace;color:#333}
.sw{display:inline-block;width:11px;height:11px;border-radius:2px;vertical-align:-1px;margin-right:5px;border:1px solid rgba(0,0,0,.12)}
hr{border:none;border-top:2px dashed #C8C4BE;margin:50px 0}
.note{font-size:11px;color:#888;font-style:italic;margin-top:10px;line-height:1.6}
</style>
</head>
<body>
<div class="page">
<!-- ══ MASTHEAD ══ -->
<div class="mh">
<h1>Globaler Zeitstrahl — Finale Spezifikation</h1>
<p>Kanonische Spezifikation für <code style="font-family:monospace;font-size:12px">/zeitstrahl</code> auf Basis von <strong>Konzept A „Der Lebensfaden"</strong>: eine durchgehende vertikale Achse, die Personen-Lebensereignisse, kuratierte Ereignisse und Briefe zu einer Erzählung in der Zeit verwebt. Dieselbe Komponente betreibt den globalen Zeitstrahl und den per-Person „Lebensweg". Enthält die vollständige Fall-Abdeckung (leere Jahre, wenige Briefe, hunderte Briefe, undatiert) und die drei Gruppierungs-Modi.</p>
<div class="tag-row">
<span class="tg">Milestone #14 · Zeitstrahl</span>
<span class="tg mint">Konzept A — final</span>
<span class="tg slate">Phone-first · honest DatePrecision</span>
</div>
<div class="byline">Familienarchiv · 2026-06-08 · Leonie Voss, UX Lead · ersetzt die A/B/C-Exploration zeitstrahl-global-concepts.html</div>
</div>
<!-- ══ 1 · ANATOMIE ══ -->
<div class="sh">
<h2>1 · Anatomie von Konzept A</h2>
<p>Eine Achse, sieben Bausteine. Die Zeit ist die Achse — Lebensereignisse &amp; Jahre als zentrierte Pillen <i>unterbrechen</i> den Faden (Text wird nie von der Linie gekreuzt), Welt-Ereignisse legen sich als Bänder quer, Briefe verdichten sich adaptiv.</p>
</div>
<div class="legend" style="margin-bottom:16px">
<div class="lg"><div class="ico" style="background:#012851;color:#a1dcd8"></div><div><div class="ttl">Lebensereignis-Pille</div><div class="body">Geburt <b>*</b> · Tod <b></b> · Heirat <b></b>. Abgeleitet aus <code>Person</code>-Daten. Zentriert, gefüllt — unterbricht die Achse. Glyphen aus <code>personLifeDates.ts</code>.</div></div></div>
<div class="lg"><div class="ico" style="background:#fff;border:2px solid #a1dcd8;color:#012851"></div><div><div class="ttl">Kuratierte Ereignis-Pille</div><div class="body"><code>PERSONAL</code> — Umzug, Auswanderung. Mint-Rand. Editierbar im Kurator-Editor.</div></div></div>
<div class="lg"><div class="ico" style="background:#EEEBE2;color:#607080;border:1px solid #607080"></div><div><div class="ttl">Welt-Band</div><div class="body"><code>HISTORICAL</code> — Krieg, Inflation. Gedämpftes Band quer über die Achse als Kontext.</div></div></div>
<div class="lg"><div class="ico" style="background:#fff;border:2px solid #a1dcd8"></div><div><div class="ttl">Einzel-Brief</div><div class="body">Kleiner Punkt + Karte, alternierend links/rechts. Wurzel-Tag-Farbchip. Link zu <code>/documents/[id]</code>.</div></div></div>
<div class="lg"><div class="ico" style="background:#a1dcd8;color:#012851;font-size:11px"></div><div><div class="ttl">Jahres-Strip</div><div class="body">Verdichtung dichter Jahre: Anzahl + 12-Monats-Sparkline. <code>MonthBucket</code> / <code>aggregateToYears</code>.</div></div></div>
<div class="lg"><div class="ico" style="background:#f0efe9;border:1px dashed #c4c0ba;color:#9a958c;font-size:11px"></div><div><div class="ttl">Lücke &amp; Ohne-Datum</div><div class="body">Ruhige/leere Jahre als dünne Span-Zeile gefaltet; <code>UNKNOWN</code>-Briefe im „Ohne Datum"-Eimer am Ende.</div></div></div>
</div>
<div class="callout navy"><strong>Gruppierungs-Umschalter</strong> (oben rechts im Zeitstrahl): <span style="display:inline-flex;border:1.5px solid #012851;border-radius:5px;overflow:hidden;vertical-align:middle;margin:0 4px"><span style="background:#012851;color:#fff;font-size:9px;font-weight:700;padding:3px 11px">Datum</span><span style="color:#012851;font-size:9px;font-weight:700;padding:3px 11px;border-left:1px solid #012851">Ereignis</span><span style="color:#012851;font-size:9px;font-weight:700;padding:3px 11px;border-left:1px solid #012851">Thema</span></span> steuert <b>nur, wie lose Briefe gebündelt werden</b>. Lebensereignisse, kuratierte Ereignisse und Welt-Bänder bleiben in allen Modi gleich auf der Achse. Standard = <b>Datum</b>.</div>
<!-- ══ 2 · GROUPING MODES ══ -->
<div class="sh">
<h2>2 · Die drei Gruppierungs-Modi</h2>
<p>Gleicher Ausschnitt (19141915), dreimal gerendert. Nur die <b>losen Briefe</b> ordnen sich um — die Achse bleibt stabil. Schmale Spaltenbreite = Phone-/Lebensweg-Form derselben Komponente.</p>
</div>
<div class="col3">
<!-- MODE: DATUM -->
<div>
<div class="modehd"><span class="seg">Datum</span> Chronologisch</div>
<div class="modesub">Standard. Briefe nach Datum; dichte Jahre verdichten zum Strip. Reine Zeit-Reihung.</div>
<div class="nf">
<div class="nsp">
<div class="nyr">1914</div>
<div class="nnode"><span class="g"></span><div class="lt">Heirat: Karl &amp; Elfriede</div><div class="lm">1914 · abgeleitet</div></div>
<div class="nwb"><div class="t">◍ Erster Weltkrieg</div><div class="s">19141918</div></div>
<div class="nletter"><span class="d"></span><div class="ncard"><div class="t">✉ Kriegsausbruch — Brief an die Familie</div><div class="m">Karl → Elfriede · 4. Aug 1914</div></div></div>
<div class="nyr">1915</div>
<div class="nnode"><span class="g">*</span><div class="lt">Geburt: Hans Raddatz</div><div class="lm">Sommer 1915 · abgeleitet</div></div>
<div class="nstrip">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:5px"><span style="font-size:9.5px;font-weight:700;color:#012851">✉ 24 Briefe</span><span style="font-size:7px;color:#8a8a86">Monate ▾</span></div>
<div style="display:flex;align-items:flex-end;gap:1.5px;height:20px"><div style="flex:1;height:20%;background:#a1dcd8"></div><div style="flex:1;height:35%;background:#a1dcd8"></div><div style="flex:1;height:55%;background:#a1dcd8"></div><div style="flex:1;height:70%;background:#a1dcd8"></div><div style="flex:1;height:60%;background:#a1dcd8"></div><div style="flex:1;height:85%;background:#a1dcd8"></div><div style="flex:1;height:100%;background:#a1dcd8"></div><div style="flex:1;height:88%;background:#a1dcd8"></div><div style="flex:1;height:72%;background:#a1dcd8"></div><div style="flex:1;height:48%;background:#a1dcd8"></div><div style="flex:1;height:55%;background:#a1dcd8"></div><div style="flex:1;height:40%;background:#a1dcd8"></div></div>
</div>
</div>
</div>
</div>
<!-- MODE: EREIGNIS -->
<div>
<div class="modehd"><span class="seg">Ereignis</span> Kuratiert</div>
<div class="modesub">Briefe bündeln unter kuratierte Ereignisse (<code>TimelineEvent.documents</code>). Erzählende Cluster statt Listen.</div>
<div class="nf">
<div class="nsp">
<div class="nyr">1914</div>
<div class="nnode"><span class="g"></span><div class="lt">Heirat: Karl &amp; Elfriede</div><div class="lm">1914 · abgeleitet</div></div>
<div class="nwb"><div class="t">◍ Erster Weltkrieg</div><div class="s">19141918</div></div>
<div class="nyr">1915</div>
<div class="nnode"><span class="g">*</span><div class="lt">Geburt: Hans Raddatz</div><div class="lm">Sommer 1915 · abgeleitet</div></div>
<div class="nletter"><span class="d"></span><div class="ncard" style="border-left:2px solid #a1dcd8"><div class="t">✉ Briefe von der Front · 24</div><div class="m">Karl ⇄ Elfriede &amp; Hans · 1915 ▾</div><span class="chip krieg" style="margin-top:4px"><i></i>Krieg</span></div></div>
<div class="nletter"><span class="d"></span><div class="ncard" style="border-left:2px solid #a1dcd8"><div class="t">✉ Weihnachten 1915 · 3</div><div class="m">kuratiertes Ereignis ▾</div><span class="chip weih" style="margin-top:4px"><i></i>Weihnachten</span></div></div>
</div>
</div>
</div>
<!-- MODE: THEMA -->
<div>
<div class="modehd"><span class="seg">Thema</span> Nach Wurzel-Tag</div>
<div class="modesub">Optional (Post-MVP). Lose Briefe je Jahr in Wurzel-Tag-Eimer; Mehrfach-Tags dedupliziert auf den primären.</div>
<div class="nf">
<div class="nsp">
<div class="nyr">1914</div>
<div class="nnode"><span class="g"></span><div class="lt">Heirat: Karl &amp; Elfriede</div><div class="lm">1914 · abgeleitet</div></div>
<div class="nwb"><div class="t">◍ Erster Weltkrieg</div><div class="s">19141918</div></div>
<div class="nbucket"><span class="chip krieg" style="margin:0"><i></i>Krieg</span><span style="font-size:8px;color:#8a8a86">6 ▾</span></div>
<div class="nyr">1915</div>
<div class="nnode"><span class="g">*</span><div class="lt">Geburt: Hans Raddatz</div><div class="lm">Sommer 1915 · abgeleitet</div></div>
<div class="nbucket"><span class="chip krieg" style="margin:0"><i></i>Krieg Briefe von der Front</span><span style="font-size:8px;color:#8a8a86">24 ▾</span></div>
<div class="nbucket"><span class="chip weih" style="margin:0"><i></i>Weihnachten</span><span style="font-size:8px;color:#8a8a86">3 ▾</span></div>
<div class="nbucket"><span class="chip fam" style="margin:0"><i></i>Familie</span><span style="font-size:8px;color:#8a8a86">2 ▾</span></div>
</div>
</div>
<div class="note" style="margin-top:8px">Hinweis im UI: „Brief mit mehreren Tags erscheint unter seinem primären Tag."</div>
</div>
</div>
<!-- ══ 3 · ALL CASES PREVIEW ══ -->
<div class="sh">
<h2>3 · Vollständige Vorschau — alle Dichte-Fälle</h2>
<p>Ein durchgehender Zeitstrahl (Desktop, zentrale Achse) von 1899 bis „Ohne Datum". Jeder Dichte-Fall kommt genau einmal vor — von leeren Jahren bis zu hunderten Briefen.</p>
</div>
<div class="callout navy" style="margin-bottom:18px">
<strong>Abgedeckte Fälle:</strong>
① leere Jahre (gefaltete Lücke) ·
② wenige Briefe ≤ 3 (einzelne Karten) ·
③ hunderte Briefe (Jahres-Strip + Sparkline) ·
④ kuratiertes Ereignis &amp; Welt-Band ·
⑤ ungenaue Präzision (<code>Sommer</code>, <code>ca.</code>) ·
⑥ undatierte Briefe (Ohne-Datum-Eimer).
</div>
<div class="tl-canvas">
<div class="dh">Zeitstrahl</div>
<div class="dh-sub">Die Familie Raddatz · 18991950 · 412 Briefe · 38 Ereignisse &nbsp;·&nbsp; <span style="color:#012851">Gruppierung: Datum</span></div>
<div style="text-align:center;margin-bottom:8px"><span class="casetag">① Leere Jahre → gefaltet</span></div>
<div class="axis">
<!-- ① empty years gap -->
<div class="gap"><span class="ln"></span><b>1899 1908</b> · keine Einträge<span class="ln"></span></div>
<!-- ② 3 letters -->
<div class="ybadge"><span>1909</span></div>
<div style="text-align:center;margin-bottom:4px"><span class="casetag">② Wenige Briefe → einzeln</span></div>
<div class="lrow"><div class="half a"><div class="lcard"><div class="t">✉ Brief aus Stettin</div><div class="m">Elfriede → Karl · Mai 1909</div><span class="chip fam"><i></i>Familie</span></div></div><div class="dot"></div><div class="half b"></div></div>
<div class="lrow"><div class="half a"></div><div class="dot"></div><div class="half b"><div class="lcard"><div class="t">✉ Geburtstagsgruß</div><div class="m">Karl → Hans · Sep 1909</div></div></div></div>
<div class="lrow"><div class="half a"><div class="lcard"><div class="t">✉ Brief zum Jahresende</div><div class="m">Karl → Elfriede · Dez 1909</div><span class="chip weih"><i></i>Weihnachten</span></div></div><div class="dot"></div><div class="half b"></div></div>
<!-- ④ life event + world band -->
<div class="ybadge"><span>1914</span></div>
<div class="pill"><span class="inner"><span class="gly"></span><span class="tx"><span class="t">Heirat: Karl &amp; Elfriede Raddatz</span><span class="s">1914 · abgeleitet aus Beziehung</span></span></span></div>
<div style="text-align:center;margin-bottom:6px"><span class="casetag">④ Welt-Band (RANGE 19141918)</span></div>
<div class="wband"><span class="t">◍ Erster Weltkrieg</span><span class="s">19141918 · historisch · 187 Briefe in dieser Zeit</span></div>
<!-- ③ hundreds of letters strip -->
<div class="ybadge"><span>1915</span></div>
<div style="text-align:center;margin-bottom:6px"><span class="casetag">③ Hunderte Briefe → Jahres-Strip</span> &nbsp; <span class="casetag" style="background:#607080">⑤ „Sommer 1915"</span></div>
<div class="pill"><span class="inner"><span class="gly">*</span><span class="tx"><span class="t">Geburt: Hans Raddatz</span><span class="s">Sommer 1915 · abgeleitet · SEASON</span></span></span></div>
<div class="strip">
<div class="hd"><span class="ct">✉ 187 Briefe</span><span class="ex">Monats-Dichte · antippen → Monate → Briefe ▾</span></div>
<div class="spark"><div style="height:22%"></div><div style="height:30%"></div><div style="height:48%"></div><div style="height:66%"></div><div style="height:58%"></div><div style="height:80%"></div><div style="height:100%"></div><div style="height:92%"></div><div style="height:74%"></div><div style="height:50%"></div><div style="height:44%"></div><div style="height:34%"></div></div>
<div class="axl"><span>Jan 1915</span><span>Dez 1915</span></div>
</div>
<!-- empty-ish span with some letters collapsed -->
<div class="gap"><span class="ln"></span><b>1916 1922</b> · Nachkriegsjahre · 96 Briefe<span class="ln"></span></div>
<!-- ⑤ approx precision world band -->
<div style="text-align:center;margin-bottom:6px"><span class="casetag" style="background:#607080">⑤ „ca. 1923" → APPROX</span></div>
<div class="wband"><span class="t">◍ Hyperinflation</span><span class="s">ca. 1923 · historisch</span></div>
<!-- curated personal event -->
<div class="ybadge"><span>1924</span></div>
<div class="pill curated"><span class="inner"><span class="gly"></span><span class="tx"><span class="t">Auswanderung nach Argentinien</span><span class="s">Frühjahr 1924 · persönlich · kuratiert</span></span></span></div>
<!-- tail empty -->
<div class="gap"><span class="ln"></span><b>1925 1950</b> · keine Einträge<span class="ln"></span></div>
</div>
<!-- ⑥ undated bucket -->
<div style="text-align:center;margin:6px 0"><span class="casetag" style="background:#7a756c">⑥ Undatiert → eigener Eimer am Ende</span></div>
<div class="undated">
<div class="h">Ohne Datum · 11 Briefe</div>
<div style="display:flex;align-items:center;gap:8px;padding:4px 0;border-top:1px solid #f0eee8"><span style="width:5px;height:5px;border-radius:50%;background:#c4c0ba;flex-shrink:0"></span><span style="font-size:9.5px;color:#3a3a36;flex:1">Brief ohne Jahresangabe</span><span style="font-size:8px;color:#aaa">Präzision UNKNOWN</span></div>
<div style="display:flex;align-items:center;gap:8px;padding:4px 0"><span style="width:5px;height:5px;border-radius:50%;background:#c4c0ba;flex-shrink:0"></span><span style="font-size:9.5px;color:#3a3a36;flex:1">Fragment, Absender unklar</span><span style="font-size:8px;color:#aaa">+ 9 weitere ▾</span></div>
</div>
</div>
<div class="note">Kein erfundenes Datum: undatierte Briefe wandern nie spekulativ in ein Jahr, sondern bleiben sichtbar im Eimer. <code>RANGE</code>-Einträge (Krieg) erscheinen einmal im Start-Jahr mit Spannen-Marker, nicht in jedem überspannten Jahr.</div>
<!-- ══ 4 · PRECISION ══ -->
<div class="sh"><h2>4 · Datums-Präzision (geteilt von Ereignissen &amp; Briefen)</h2><p>Eine Render-Logik für alle datierten Einträge — <code>dateLabel.ts</code>, gespeist von <code>DatePrecision</code>.</p></div>
<div class="rules">
<table>
<tr><th>DatePrecision</th><th>Darstellung</th><th>Beispiel</th><th>Wirkung auf der Achse</th></tr>
<tr><td>DAY</td><td>vollständiges Datum</td><td class="ex">28. Juli 1914</td><td>exakte Sortierung im Jahres-Band</td></tr>
<tr><td>MONTH</td><td>Monat + Jahr</td><td class="ex">Juli 1914</td><td>Monats-Sortierung</td></tr>
<tr><td>SEASON</td><td>Jahreszeit + Jahr</td><td class="ex">Sommer 1914</td><td>grobe Reihung</td></tr>
<tr><td>YEAR</td><td>nur Jahr</td><td class="ex">1914</td><td>ans Band-Ende</td></tr>
<tr><td>APPROX</td><td>„ca." + Jahr</td><td class="ex">ca. 1914</td><td>mit „ca."-Marker</td></tr>
<tr><td>RANGE</td><td>StartEnde</td><td class="ex">19141918</td><td>Start-Jahr, Spannen-Marker, nicht dupliziert</td></tr>
<tr><td>UNKNOWN</td><td>undatiert</td><td class="ex">Ohne Datum</td><td>eigener Eimer am Ende</td></tr>
</table>
</div>
<!-- ══ 5 · RESPONSIVE ══ -->
<div class="sh"><h2>5 · Responsiv — eine Komponente, drei Breiten</h2><p>Identisches Markup &amp; identische Daten. Nur die Achs-Position wechselt per Container-Query.</p></div>
<div class="legend" style="grid-template-columns:repeat(3,1fr)">
<div class="lg"><div class="ico" style="background:#012851;color:#a1dcd8;font-size:10px"></div><div><div class="ttl">≥ 1024px · Desktop</div><div class="body"><b>Zentrale Achse</b>, Briefe alternierend links/rechts, Welt-Bänder über volle Breite. Pillen unterbrechen die Linie.</div></div></div>
<div class="lg"><div class="ico" style="background:#012851;color:#a1dcd8;font-size:10px"></div><div><div class="ttl">&lt; 1024px · Phone</div><div class="body"><b>Linke Achse</b>, alles einseitig rechts. DOM-Reihenfolge bleibt streng chronologisch (<code>&lt;ol&gt;</code>) — Screenreader liest linear.</div></div></div>
<div class="lg"><div class="ico" style="background:#012851;color:#a1dcd8;font-size:10px"></div><div><div class="ttl">Lebensweg-Rail · 35%</div><div class="body">Gleiche linke Achse in der Personenseite (<code>&lt;TimelineView personId&gt;</code>), gefiltert auf eine Person. Rail-Tauglichkeit = Stärke von A.</div></div></div>
</div>
<!-- ══ 6 · TOKENS ══ -->
<div class="sh"><h2>6 · Design-Tokens (echte, ausgelieferte Werte)</h2><p>Aus <code>frontend/src/routes/layout.css</code>. Keine Hardcodes in der Komponente.</p></div>
<div class="impl-ref">
<table>
<tr><th>Rolle</th><th>Token</th><th>Wert</th><th>Einsatz</th></tr>
<tr><td>Achse / Knoten / Header</td><td><code>brand-navy</code></td><td><span class="sw" style="background:#012851"></span>#012851</td><td>Spine, Lebensereignis-Pillen, Jahres-Badges, Titel</td></tr>
<tr><td>Akzent / Brief-Punkt</td><td><code>brand-mint</code></td><td><span class="sw" style="background:#a1dcd8"></span>#a1dcd8</td><td>Brief-Punkte, kuratierte Pillen-Ränder, Sparkline, Dark-Mode-Auswahl</td></tr>
<tr><td>Historisch / Welt</td><td><code>tag-slate</code></td><td><span class="sw" style="background:#607080"></span>#607080</td><td>Welt-Bänder &amp; Glyphe ◍ — gedämpft</td></tr>
<tr><td>Tag-Chip-Farben</td><td><code>--c-tag-*</code> (Wurzel)</td><td><span class="sw" style="background:#5a8a6a"></span><span class="sw" style="background:#a0522d"></span><span class="sw" style="background:#c17a00"></span><span class="sw" style="background:#7a4f9a"></span></td><td>sage · sienna · amber · violet — Farbe vom Wurzel-Tag, Punkt + Label</td></tr>
<tr><td>Seite / Karte / Linie</td><td><code>canvas · surface · line</code></td><td><span class="sw" style="background:#f0efe9"></span><span class="sw" style="background:#fff"></span><span class="sw" style="background:#e4e2d7"></span></td><td>#f0efe9 · #ffffff · #e4e2d7</td></tr>
<tr><td>Text sekundär</td><td><code>text-ink-3</code></td><td><span class="sw" style="background:#6b7280"></span>#6b7280</td><td>Meta-Zeilen (4,8:1 auf weiß — AA ✓)</td></tr>
<tr><td>Schrift</td><td><code>font-serif · font-sans</code></td><td>Tinos · Montserrat</td><td>Namen/Titel serif · Labels/Chrome sans</td></tr>
<tr><td>Lebensdaten-Glyphen</td><td><code>personLifeDates.ts</code></td><td>* † ⚭</td><td>Geburt · Tod · Heirat — konsistent mit Personenkarten</td></tr>
</table>
</div>
<!-- ══ 7 · IMPL-REF ══ -->
<div class="sh"><h2>7 · Implementierungs-Referenz &amp; Barrierefreiheit</h2><p>Domain-Ordner <code>frontend/src/lib/timeline/</code>; Route <code>/zeitstrahl</code>; Backend <code>GET /api/timeline</code>.</p></div>
<div class="impl-ref">
<table>
<tr><th>Baustein</th><th>Komponente / Datei</th><th>Verantwortung</th></tr>
<tr><td>Orchestrator</td><td><code>TimelineView.svelte</code></td><td>Lädt <code>/api/timeline</code>; optionaler <code>personId</code> für globalen vs. Lebensweg-Modus; hält den Gruppierungs-Modus</td></tr>
<tr><td>Jahres-Band</td><td><code>YearBand.svelte</code></td><td>Jahres-Badge + Einträge; Lücken-Faltung ruhiger Spannen</td></tr>
<tr><td>Ereignis-Pille</td><td><code>EventCard.svelte</code></td><td>PERSONAL / HISTORICAL / abgeleitet; zentrierte Pille bzw. Welt-Band; präzisions-bewusstes Label</td></tr>
<tr><td>Brief-Karte</td><td><code>LetterCard.svelte</code></td><td>Einzel-Brief, alternierende Seite, Wurzel-Tag-Chip, Link <code>/documents/[id]</code></td></tr>
<tr><td>Jahres-Strip</td><td><code>YearLetterStrip.svelte</code></td><td>Adaptive Verdichtung ab Schwellwert; 12-Monats-Sparkline aus <code>MonthBucket</code> / <code>aggregateToYears</code> (<code>lib/document/timeline.ts</code>)</td></tr>
<tr><td>Datums-Helfer</td><td><code>dateLabel.ts</code></td><td><code>DatePrecision</code> → deutsches Label; geteilt von Ereignissen &amp; Briefen</td></tr>
<tr><td>Kurator-Editor</td><td><code>/zeitstrahl/events/new · [id]/edit</code></td><td>Ereignis anlegen/bearbeiten; Personen- + Dokument-Mehrfach-Picker (Bulk-Linking); <code>WRITE_ALL</code></td></tr>
<tr><td>Quick-Add</td><td><code>DocumentTimelineEventPicker.svelte</code></td><td>Auf <code>/documents/[id]</code>: Ereignis wählen/neu anlegen; verlinkt einen Brief</td></tr>
<tr><td>Daten-API</td><td><code>GET /api/timeline</code></td><td>Verschmilzt kuratierte + abgeleitete Ereignisse + Briefe in <code>TimelineDTO</code> (Jahres-Eimer + Ohne-Datum); Filter <code>personId · type · fromYear · toYear</code></td></tr>
<tr><td>Barrierefreiheit</td><td></td><td>Achse = <code>&lt;ol&gt;</code>, chronologische DOM-Reihenfolge; ◍ ✉ * nie nur Farbe — Glyphe + Label; 44px-Tap-Ziele; <code>prefers-reduced-motion</code>; axe in Light &amp; Dark</td></tr>
</table>
</div>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,182 @@
# Family Timeline (Zeitstrahl) — Design Spec
**Date:** 2026-06-07
**Status:** Approved — pending implementation plan
## Problem
The archive can capture, transcribe, organize, and browse letters, but the transcribed material does not yet add up to a *story in time*. Readers (younger, phone-first) have no way to feel the family's history unfold; transcribers don't see their work become something larger. A previous attempt to derive meaning automatically (LLM search) was slow and low-quality, so the family is wary of auto-extraction from handwriting.
## Goal
A **hand-curated, year-banded vertical timeline** — the "Zeitstrahl" — that weaves three layers into one chronological view:
1. **Person life-events** derived from already-curated structured data (`Person` birth/death dates, marriage years from `PersonRelationship.fromYear`). Trusted, free, no extra entry. (Requires the Person birth/death fields to move from year-integers to date + precision — see foundational issue 1.)
2. **Hand-curated events** the family writes — both **personal** (a move, an illness, emigration) and **historical** (a war, hyperinflation). Editorially controlled, always correct.
3. **Letters**, auto-placed by their existing `documentDate`, optionally hand-linked to an event to cluster them.
Two surfaces, one component:
- **Global timeline** at `/zeitstrahl`.
- **Per-person "Lebensweg"** — the same view filtered to one person, embedded on the Person detail page.
Built for phones (vertical scroll), honest about date precision, with no fabricated dates.
### Non-goals (YAGNI)
- ❌ Auto-extracting events from transcription text — explicitly avoided; this is what makes the feature trustworthy.
- ❌ Importing an external historical-events dataset — historical events are hand-entered too.
- ❌ A map / geographic view — that is a separate future feature (B2).
- ❌ Per-derived-event hide/override toggle — deferred refinement; MVP shows all derived events.
- ❌ Day-resolution timeline axis — the axis is the **year**; finer dates only affect within-band ordering and label text.
## Core principle: the year is the axis
Most dates in the archive are year-only (birth/death/marriage years are years by nature; many letters carry `YEAR`/`APPROX` precision). Therefore:
- The timeline spine is a **sequence of year bands**. Everything for a given year lives in that band.
- **Finer ordering only when we have it.** A `DAY`-precision letter (`1923-04-12`) sorts above a `YEAR`-precision one (`1923`) *within* the 1923 band; we never invent a day we don't have.
- **An "Ohne Datum" bucket** at the end holds items with `UNKNOWN` precision.
- **Honest precision rendering** reuses the existing `DatePrecision` enum for every dated item (events and letters share one rendering path).
### Date rendering (shared by events and letters)
| `DatePrecision` | German render | Example |
|---|---|---|
| `DAY` | full date | `28. Juli 1914` |
| `MONTH` | month + year | `Juli 1914` |
| `SEASON` | season + year | `Sommer 1914` |
| `YEAR` | year only | `1914` |
| `APPROX` | "ca." + year | `ca. 1914` |
| `RANGE` | startend year | `19141918` |
| `UNKNOWN` | undated bucket | `Ohne Datum` |
A `RANGE` item is shown in its **start year's band** with a span marker; it is not duplicated across every year it covers.
## Data model
A new `timeline/` domain package on the backend (kept deliberately separate from the in-flight Lesereisen/`Geschichte` work in #750753).
### `TimelineEvent` entity
Mirrors the `Document` date model for consistency, so events and letters use one date-handling code path.
| Field | Type | Notes |
|---|---|---|
| `id` | `UUID` | `@GeneratedValue(UUID)` |
| `title` | `String` | required |
| `type` | `EventType` enum | `PERSONAL`, `HISTORICAL` |
| `eventDate` | `LocalDate` | required — most precise date known (WW1 → `1914-07-28`; vague year → `1920-01-01`) |
| `precision` | `DatePrecision` | reuse existing enum; default `YEAR` — governs rendering & whether the day matters |
| `eventDateEnd` | `LocalDate` (nullable) | only set when `precision == RANGE` |
| `description` | `TEXT` (nullable) | free-text narrative for the event |
| `persons` | ManyToMany `Person` | who the event involves; drives the per-person view & filtering |
| `documents` | ManyToMany `Document` | optional hand-linked supporting letters (the "cluster letters to an event" feature) |
| `createdBy` / `createdAt` / `updatedBy` / `updatedAt` / `version` | audit | standard entity conventions |
- `@Schema(requiredMode = REQUIRED)` on every always-populated field (`id`, `title`, `type`, `eventDate`, `precision`).
- Collections use `@Builder.Default new HashSet<>()`.
- New Flyway migration adds `timeline_events`, `timeline_event_persons`, `timeline_event_documents` join tables.
### `EventType` enum
`PERSONAL` | `HISTORICAL`. Personal events render with a person/family accent; historical events with a "world" accent and muted styling so the two layers are visually separable.
### Prerequisite: migrate `Person` birth/death to date + precision
Today `Person` stores `birthYear`/`deathYear` as `Integer`, so a known exact birthday (e.g. `1901-03-14`) has nowhere to live and derived events are stuck at year precision. This is fixed by a **foundational Person-domain migration** that the timeline depends on (and which delivers value on its own — precise dates then render on person cards, hover cards, and the Stammbaum).
**Change:** replace `birthYear`/`deathYear` (`Integer`) with:
| Field | Type | Notes |
|---|---|---|
| `birthDate` | `LocalDate` (nullable) | most precise date known |
| `birthDatePrecision` | `DatePrecision` (nullable) | `YEAR` for year-only, `DAY` for exact birthdays, etc. |
| `deathDate` | `LocalDate` (nullable) | |
| `deathDatePrecision` | `DatePrecision` (nullable) | |
**Flyway data migration:** existing `birth_year``birth_date = '{year}-01-01'`, `birth_date_precision = 'YEAR'` (same for death); then drop the year columns.
**Re-import preservation (ADR-025):** the canonical importer (`PersonRegisterImporter` / `tools/import-normalizer/persons_tree.py`) only carries the *year*. On re-import it must **not** clobber a hand-entered finer-than-`YEAR` date — if the existing precision is `DAY`/`MONTH`/`SEASON`, preserve it; only refresh from the spreadsheet year when the field is empty or still `YEAR`-from-import.
**Bounding the blast radius:** `PersonNodeDTO` keeps exposing an `Integer birthYear`/`deathYear` *derived* from the new date (`birthDate.getYear()`), so the Stammbaum layout (`familyForest.ts` et al.) is untouched. Display surfaces (person card, hover card) move to a shared precision-aware formatter — extend the existing `frontend/src/lib/person/personLifeDates.ts`. The person edit/new forms gain date inputs with a precision selector.
**Scope note:** `PersonRelationship.fromYear` (marriage year) stays `Integer`/`YEAR` for MVP — precise marriage dates are a later, parallel extension if wanted.
### Derived person-events (not persisted)
Assembled on read from the migrated `Person` data; never stored:
| Source | Derived event | `eventDate` | precision |
|---|---|---|---|
| `Person.birthDate` | *Geburt: {name}* | `Person.birthDate` | `Person.birthDatePrecision` |
| `Person.deathDate` | *Tod: {name}* | `Person.deathDate` | `Person.deathDatePrecision` |
| `PersonRelationship` `SPOUSE_OF.fromYear` | *Heirat: {A} & {B}* | `{fromYear}-01-01` | `YEAR` |
Emitted in the same DTO shape as a curated event, flagged `derived: true`, `type = PERSONAL`. They cannot be edited from the timeline (they are edited at their source: Person record / relationship). A marriage is derived once per `SPOUSE_OF` edge (symmetric edges are stored once — see existing relationship rules).
### Letters
Placed by `Document.documentDate`:
- Band = `documentDate.getYear()`; `UNKNOWN` precision → "Ohne Datum" bucket.
- Sub-ordered within a band by full date when precision allows.
- A letter may also appear under an event it's linked to (via `TimelineEvent.documents`) as a cluster, in addition to its own band placement.
## Assembly & API
A `TimelineService` merges the three layers into a year-bucketed DTO for the requested scope and filters. Layering rules apply: the service owns `TimelineEventRepository` and reaches Person/Document/Relationship data through their **services**, never their repositories.
### DTOs
- `TimelineEntryDTO` — one renderable item: `kind` (`EVENT` | `LETTER`), `eventDate`, `precision`, `eventDateEnd`, `title`, `type` (for events), `derived` flag, plus the source id (eventId / documentId) and minimal display fields (sender/receiver names for letters, linked person ids for events).
- `TimelineYearDTO``{ year: int, entries: TimelineEntryDTO[] }`.
- `TimelineDTO``{ years: TimelineYearDTO[], undated: TimelineEntryDTO[] }`.
### Endpoints
- `GET /api/timeline` — global timeline. Query params (all optional): `personId`, `generation`, `type` (`PERSONAL`/`HISTORICAL`), `fromYear`, `toYear`. The per-person "Lebensweg" is just `GET /api/timeline?personId=…` — no separate endpoint. Requires `READ_ALL`.
- `POST /api/timeline/events` — create a curated event. `@RequirePermission(Permission.WRITE_ALL)`.
- `PUT /api/timeline/events/{id}` — update. `@RequirePermission(Permission.WRITE_ALL)`.
- `DELETE /api/timeline/events/{id}` — delete. `@RequirePermission(Permission.WRITE_ALL)`.
- `GET /api/timeline/events/{id}` — fetch a single event for the edit form. Requires `READ_ALL`.
Input DTO `TimelineEventRequest` lives flat in the `timeline/` package. Errors use `DomainException.notFound/...`; **no new `ErrorCode`** is required. Run `npm run generate:api` after backend model/endpoint changes.
## Frontend
- New domain dir `frontend/src/lib/timeline/`:
- `TimelineView.svelte` — orchestrator; accepts an optional `personId` prop so the same component powers both global and per-person views.
- `YearBand.svelte` — one year section header + its entries.
- `EventCard.svelte` — renders a `PERSONAL`/`HISTORICAL`/derived event with precision-aware date label.
- `LetterCard.svelte` — compact letter row (sender → receiver, snippet/title, date), links to `/documents/[id]`.
- `TimelineFilters.svelte` — person, generation, layer toggles, year range.
- `dateLabel.ts` — the shared precision→label helper (reuse/extend `lib/document/timeline.ts` helpers like `formatTickLabel` where they fit).
- Routes:
- `/zeitstrahl` — global timeline (`+page.server.ts` loads `/api/timeline`).
- `/zeitstrahl/events/new` and `/zeitstrahl/events/[id]/edit` — curator forms, gated to `WRITE_ALL`, using the form-actions pattern.
- Person detail page gains a **Lebensweg** card section embedding `<TimelineView personId={person.id} />`.
- Styling per project conventions (card pattern, brand tokens, `font-serif` for names/titles, `BackButton`, mobile-first at 375px, dark-mode tokens).
- i18n keys added to `messages/{de,en,es}.json` (German primary).
## Testing
- Backend: `TimelineService` assembly/merge/sort/precision-bucketing (unit + `@DataJpaTest` against Postgres via Testcontainers); controller permission gating; derived-event assembly (birth/death/marriage, symmetric marriage dedup).
- Frontend: `dateLabel.ts` precision rendering; `TimelineView` global vs `personId` modes (`*.svelte.spec.ts`); filter behavior.
- Follow project test discipline: targeted single-file runs locally only; full sweep left to CI.
## Proposed issue breakdown (milestone "Zeitstrahl / Family Timeline")
Ordered so each issue is independently shippable and reviewable; later issues depend on earlier ones. Issue 1 is a standalone Person-domain improvement and a hard prerequisite for the timeline's derived events.
1. **Person birth/death → date + precision (foundational)** — replace `birthYear`/`deathYear` with `birthDate`/`deathDate` + precision on `Person`; Flyway data migration (year → `YYYY-01-01`, `YEAR`); update importer with re-import preservation rule; derive year in `PersonNodeDTO` (Stammbaum untouched); move person card / hover card to a precision-aware `personLifeDates.ts`; add date+precision inputs to person new/edit forms. Ships value on its own.
2. **Backend: `TimelineEvent` entity + migration** — entity, `EventType`, Flyway migration + join tables, repository.
3. **Backend: TimelineEvent CRUD API**`TimelineEventController` + `TimelineService` write methods, `TimelineEventRequest` DTO, permission gating, `GET /events/{id}`.
4. **Backend: derived person-events** — assemble Geburt/Tod/Heirat from migrated Person + relationship data via their services; unit-tested dedup.
5. **Backend: timeline assembly endpoint**`GET /api/timeline` merging events + derived events + letters into `TimelineDTO`; year-bucketing, precision sort, undated bucket, filters.
6. **Frontend: shared date-label helper + types**`dateLabel.ts`, regen API types.
7. **Frontend: global `/zeitstrahl` view**`TimelineView`, `YearBand`, `EventCard`, `LetterCard`, server load.
8. **Frontend: filters**`TimelineFilters` (person / generation / layer / year range).
9. **Frontend: curator event forms**`/zeitstrahl/events/new` + `/[id]/edit`, gated, with document & person pickers.
10. **Frontend: per-person Lebensweg** — embed `<TimelineView personId>` on Person detail.
11. **Polish & a11y** — mobile layout at 375px, dark mode, axe checks, i18n completeness (de/en/es).
> An ADR may be warranted for the new `timeline/` domain + entity (per `docs/CLAUDE.md`, significant data-model change). Add as the next sequential ADR number when implementation starts.

View File

@@ -0,0 +1,188 @@
# spaCy NLP Service — Design Spec
**Date:** 2026-06-07
**Status:** Prototype
## Problem
The current NL search uses Ollama (`qwen2.5:7b-instruct-q4_K_M`) to parse free-text queries into structured extractions (person names, dates, role, keywords). Inference takes 515 seconds per query, making the feature too slow to be useful compared to filling in the filter UI manually.
## Goal
Build a standalone `nlp-service/` prototype that replaces Ollama with spaCy for query parsing. The prototype is scoped to **extraction quality evaluation** — run it locally, curl it with real archive queries, and measure whether spaCy extracts names/dates/keywords well enough to justify a full migration. No Java-side changes in this iteration.
## Extraction Contract
The service must produce an output compatible with the existing `OllamaExtraction` Java record:
| Field | Type | Description |
|---|---|---|
| `personNames` | `string[]` | Names of persons mentioned, left-to-right order |
| `personRole` | `"sender"` \| `"receiver"` \| `"any"` | Role of the person(s) in the document |
| `dateFrom` | `string \| null` | ISO 8601 date `YYYY-MM-DD` or null |
| `dateTo` | `string \| null` | ISO 8601 date `YYYY-MM-DD` or null |
| `keywords` | `string[]` | Content words — fuzzy-matched against tags by Java |
| `rawQuery` | `string` | Echo of the input query |
**Two-person ordering:** `personNames` must be in left-to-right span order. Java maps `[0]` → sender, `[1]` → receiver.
**`rawQuery` note:** In the current Java code `rawQuery` is set by the caller, not parsed from Ollama. The service echoes the input for convenience; the eventual `RestClientSpacyClient` will set it from the input directly, same as today.
## Architecture
```
nlp-service/
├── main.py # FastAPI app — /parse and /health endpoints
├── extractor.py # NLP pipeline: NER → role → dates → keywords
├── models.py # Pydantic request/response types
├── requirements.txt
├── Dockerfile
└── CLAUDE.md
```
Sits alongside `ocr-service/` in the repo. For the prototype it runs standalone (no docker-compose wiring).
## Extraction Pipeline (`extractor.py`)
Five steps run in sequence on each query.
### Step 1 — NER pass
Run spaCy on the query using the model for the requested language. Collect:
- All `PER` spans → candidates for `personNames`
- All `DATE` spans → raw text strings for step 3
### Step 2 — Role detection
Only relevant when exactly **one** PER entity is found. Walk the dependency tree of the PER span's root token; check if a governing `case` or `prep` token matches the sender or receiver preposition set for the language:
| Language | Sender prepositions | Receiver prepositions |
|---|---|---|
| `de` | von, vom | an, nach, für |
| `en` | from, by | to, for |
| `es` | de, por | para, a |
- One person + sender preposition → `personRole = "sender"`
- One person + receiver preposition → `personRole = "receiver"`
- One person + no match / two or more persons → `personRole = "any"`
Two-person queries always return `"any"` — Java derives direction from position.
### Step 3 — Date parsing
For each DATE span, inspect the token immediately before the span to detect range direction:
| Direction token | Effect |
|---|---|
| vor / before / antes de | Span → `dateTo` |
| nach / after / después de | Span → `dateFrom` |
| zwischen…und / between…and / entre…y | Earlier span → `dateFrom`, later → `dateTo` |
| No direction token (bare year/date) | Span → both `dateFrom` and `dateTo` set to that year (year-range, Jan 1Dec 31) |
`dateparser.parse()` with `PREFER_DAY_OF_MONTH=first` converts the span text to a Python `date`. For `dateTo` results that resolve to a year boundary, set to Dec 31 of that year (mirrors `RestClientOllamaClient.parseDate()` behaviour).
Output as ISO strings (`YYYY-MM-DD`) or `null`.
### Step 4 — Keyword extraction
Collect tokens that satisfy all of:
- POS tag is `NOUN` or `PROPN`
- Not a stopword
- Not inside any NER span (PER or DATE)
- Lemma length ≥ 3
Output as lowercased lemmas. These are fuzzy-matched against the tags table by `NlQueryParserService.resolveTags()` on the Java side — no tag lookup in the Python service.
Examples:
- "Briefe aus dem Krieg" → `keywords: ["brief", "krieg"]`
- "Texte über Weihnachten" → `keywords: ["text", "weihnachten"]`
### Step 5 — Assembly
```json
{
"personNames": ["Opa Hermann", "Marie"],
"personRole": "any",
"dateFrom": null,
"dateTo": "1920-12-31",
"keywords": ["brief"],
"rawQuery": "Briefe von Opa Hermann an Marie vor 1920"
}
```
## API
### `POST /parse`
**Request:**
```json
{ "query": "Briefe von Opa Hermann an Marie vor 1920", "lang": "de" }
```
`lang` is a required enum: `"de"` | `"en"` | `"es"`. Unknown values → HTTP 422 (FastAPI validation).
**Response:** extraction object as above, HTTP 200.
**Error:** pipeline crash → HTTP 500 `{"detail": "..."}`.
### `GET /health`
Returns HTTP 200 `{"status": "ok"}` when all three models are loaded.
## Language Models
| `lang` | spaCy model |
|---|---|
| `de` | `de_core_news_sm` |
| `en` | `en_core_web_sm` |
| `es` | `es_core_news_sm` |
All three models are loaded at startup and held in memory. Routing is by the `lang` field on the request.
## Dockerfile
Mirrors `ocr-service/``python:3.11-slim`, non-root user, models baked into the image:
```dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN python -m spacy download de_core_news_sm \
&& python -m spacy download en_core_web_sm \
&& python -m spacy download es_core_news_sm
COPY . .
RUN useradd --no-create-home --shell /usr/sbin/nologin --uid 1001 nlp \
&& chown -R nlp:nlp /app
USER nlp
EXPOSE 8001
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]
```
Image size: ~350 MB. No volume needed — models live in the image layer.
## Local Dev
```bash
cd nlp-service
pip install -r requirements.txt
python -m spacy download de_core_news_sm en_core_web_sm es_core_news_sm
uvicorn main:app --reload --port 8001
curl -X POST http://localhost:8001/parse \
-H "Content-Type: application/json" \
-d '{"query": "Briefe von Opa Hermann an Marie vor 1920", "lang": "de"}'
```
## Known Limitations
- **Historical names:** spaCy models are trained on modern news corpora. Unusual 18991950 German names may not score as `PER`. Mitigation: the Java `resolveNames()` already does fuzzy matching against the persons table, so partial name extraction is recoverable.
- **Role detection:** the preposition sets are a fixed enumeration (~12 tokens across 3 languages). Sentences that express direction without one of these prepositions will fall through to `personRole = "any"`. This is acceptable — `"any"` is the safe default and searches both sender and receiver positions.
- **"über Oma" ambiguity:** if spaCy recognises "Oma" as a PER entity it lands in `personNames` (person search); if not, it lands in `keywords` (tag search via Java). Both paths return relevant results. The prototype evaluation will reveal which path dominates for real archive queries.
## Out of Scope (prototype)
- docker-compose integration (Ollama replacement)
- Java-side changes (`RestClientSpacyClient`, rename `OllamaClient``NlParserClient`)
- Tag lookup inside the Python service
- Automated test suite (pytest fixtures) — evaluation is done by curling the running service

View File

@@ -84,22 +84,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/persons/{id}/confirm": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch: operations["confirmPerson"];
trace?: never;
};
"/api/documents/{id}": {
parameters: {
query?: never;
@@ -708,6 +692,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/admin/backfill-titles": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["backfillTitles"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/backfill-file-hashes": {
parameters: {
query?: never;
@@ -740,6 +740,22 @@ export interface paths {
patch: operations["patchFamilyMember"];
trace?: never;
};
"/api/persons/{id}/confirm": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch: operations["confirmPerson"];
trace?: never;
};
"/api/notifications/{id}/read": {
parameters: {
query?: never;
@@ -859,7 +875,7 @@ export interface paths {
path?: never;
cookie?: never;
};
get: operations["search"];
get: operations["search_1"];
put?: never;
post?: never;
delete?: never;
@@ -1323,7 +1339,7 @@ export interface paths {
path?: never;
cookie?: never;
};
get: operations["search_1"];
get: operations["search_2"];
put?: never;
post?: never;
delete?: never;
@@ -1651,7 +1667,7 @@ export interface components {
/** Format: int32 */
deathYear?: number;
/** Format: int32 */
generation?: number | null;
generation?: number;
};
Person: {
/** Format: uuid */
@@ -1668,7 +1684,7 @@ export interface components {
/** Format: int32 */
deathYear?: number;
/** Format: int32 */
generation?: number | null;
generation?: number;
familyMember: boolean;
sourceRef?: string;
provisional: boolean;
@@ -1803,6 +1819,75 @@ export interface components {
/** Format: uuid */
targetId: string;
};
Pageable: {
/** Format: int32 */
page?: number;
/** Format: int32 */
size?: number;
sort?: string[];
};
ActivityActorDTO: {
initials: string;
color: string;
name?: string;
};
DocumentListItem: {
/** Format: uuid */
id: string;
title: string;
originalFilename: string;
thumbnailUrl?: string;
/** Format: date */
documentDate?: string;
/** @enum {string} */
metaDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
/** Format: date */
metaDateEnd?: string;
sender?: components["schemas"]["Person"];
receivers: components["schemas"]["Person"][];
tags: components["schemas"]["Tag"][];
archiveBox?: string;
archiveFolder?: string;
location?: string;
summary?: string;
/** Format: int32 */
completionPercentage: number;
contributors: components["schemas"]["ActivityActorDTO"][];
matchData: components["schemas"]["SearchMatchData"];
/** Format: date-time */
createdAt: string;
/** Format: date-time */
updatedAt: string;
};
DocumentSearchResult: {
items: components["schemas"]["DocumentListItem"][];
/** Format: int64 */
totalElements: number;
/** Format: int32 */
pageNumber: number;
/** Format: int32 */
pageSize: number;
/** Format: int32 */
totalPages: number;
/** Format: int64 */
undatedCount: number;
};
MatchOffset: {
/** Format: int32 */
start: number;
/** Format: int32 */
length: number;
};
SearchMatchData: {
transcriptionSnippet?: string;
titleOffsets: components["schemas"]["MatchOffset"][];
senderMatched: boolean;
matchedReceiverIds: string[];
matchedTagIds: string[];
snippetOffsets: components["schemas"]["MatchOffset"][];
summarySnippet?: string;
summaryOffsets: components["schemas"]["MatchOffset"][];
};
CreateRelationshipRequest: {
/** Format: uuid */
relatedPersonId: string;
@@ -2188,11 +2273,6 @@ export interface components {
/** Format: int64 */
transcriptionCount: number;
};
ActivityActorDTO: {
initials: string;
color: string;
name?: string;
};
TranscriptionQueueItemDTO: {
/** Format: uuid */
id: string;
@@ -2235,25 +2315,6 @@ export interface components {
/** Format: int64 */
totalStories: number;
};
PersonSummaryDTO: {
title?: string;
/** Format: uuid */
id?: string;
displayName?: string;
firstName?: string;
lastName?: string;
/** Format: int64 */
documentCount?: number;
/** Format: int32 */
birthYear?: number;
/** Format: int32 */
deathYear?: number;
alias?: string;
notes?: string;
personType?: string;
familyMember?: boolean;
provisional?: boolean;
};
PersonSearchResult: {
items: components["schemas"]["PersonSummaryDTO"][];
/** Format: int64 */
@@ -2265,6 +2326,25 @@ export interface components {
/** Format: int32 */
totalPages: number;
};
PersonSummaryDTO: {
title?: string;
/** Format: uuid */
id?: string;
displayName?: string;
firstName?: string;
lastName?: string;
/** Format: int64 */
documentCount?: number;
notes?: string;
/** Format: int32 */
birthYear?: number;
/** Format: int32 */
deathYear?: number;
provisional?: boolean;
alias?: string;
personType?: string;
familyMember?: boolean;
};
InferredRelationshipWithPersonDTO: {
person: components["schemas"]["PersonNodeDTO"];
label: string;
@@ -2280,7 +2360,7 @@ export interface components {
/** Format: int32 */
deathYear?: number;
/** Format: int32 */
generation?: number | null;
generation?: number;
familyMember: boolean;
};
InferredRelationshipDTO: {
@@ -2433,63 +2513,6 @@ export interface components {
/** Format: int32 */
totalPages?: number;
};
DocumentListItem: {
/** Format: uuid */
id: string;
title: string;
originalFilename: string;
thumbnailUrl?: string;
/** Format: date */
documentDate?: string;
/** @enum {string} */
metaDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
/** Format: date */
metaDateEnd?: string;
sender?: components["schemas"]["Person"];
receivers: components["schemas"]["Person"][];
tags: components["schemas"]["Tag"][];
archiveBox?: string;
archiveFolder?: string;
location?: string;
summary?: string;
/** Format: int32 */
completionPercentage: number;
contributors: components["schemas"]["ActivityActorDTO"][];
matchData: components["schemas"]["SearchMatchData"];
/** Format: date-time */
createdAt: string;
/** Format: date-time */
updatedAt: string;
};
DocumentSearchResult: {
items: components["schemas"]["DocumentListItem"][];
/** Format: int64 */
totalElements: number;
/** Format: int32 */
pageNumber: number;
/** Format: int32 */
pageSize: number;
/** Format: int32 */
totalPages: number;
/** Format: int64 */
undatedCount: number;
};
MatchOffset: {
/** Format: int32 */
start: number;
/** Format: int32 */
length: number;
};
SearchMatchData: {
transcriptionSnippet?: string;
titleOffsets: components["schemas"]["MatchOffset"][];
senderMatched: boolean;
matchedReceiverIds: string[];
matchedTagIds: string[];
snippetOffsets: components["schemas"]["MatchOffset"][];
summarySnippet?: string;
summaryOffsets: components["schemas"]["MatchOffset"][];
};
IncompleteDocumentDTO: {
/** Format: uuid */
id: string;
@@ -2828,6 +2851,26 @@ export interface operations {
};
};
};
deletePerson: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
getDocument: {
parameters: {
query?: never;
@@ -3184,48 +3227,6 @@ export interface operations {
};
};
};
confirmPerson: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Person"];
};
};
};
};
deletePerson: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
createPerson: {
parameters: {
query?: never;
@@ -4117,6 +4118,26 @@ export interface operations {
};
};
};
backfillTitles: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["BackfillResult"];
};
};
};
};
backfillFileHashes: {
parameters: {
query?: never;
@@ -4163,6 +4184,28 @@ export interface operations {
};
};
};
confirmPerson: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Person"];
};
};
};
};
markOneRead: {
parameters: {
query?: never;
@@ -4443,7 +4486,7 @@ export interface operations {
};
};
};
search: {
search_1: {
parameters: {
query?: {
q?: string;
@@ -5067,7 +5110,7 @@ export interface operations {
};
};
};
search_1: {
search_2: {
parameters: {
query?: {
q?: string;

View File

@@ -81,9 +81,9 @@ $effect(() => {
onblur={onblur}
aria-label={m.docs_search_placeholder()}
placeholder={m.docs_search_placeholder()}
class="block w-full border-line py-2.5 pr-10 pl-3 placeholder-ink-3 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
class="block w-full border-line py-2.5 pr-4 pl-10 placeholder-ink-3 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
{#if isLoading}
<svg
role="status"

View File

@@ -289,19 +289,19 @@ $effect(() => {
from={from}
to={to}
onchange={(event) => {
from = event.from;
to = event.to;
// Drag commits filter + zoom atomically (Graylog-style range selector).
// Single click and clear omit zoomFrom/zoomTo so existing zoom is preserved.
if ('zoomFrom' in event) {
triggerSearchWithZoom(event.zoomFrom ?? null, event.zoomTo ?? null);
} else {
triggerSearchKeepZoom();
}
}}
from = event.from;
to = event.to;
// Drag commits filter + zoom atomically (Graylog-style range selector).
// Single click and clear omit zoomFrom/zoomTo so existing zoom is preserved.
if ('zoomFrom' in event) {
triggerSearchWithZoom(event.zoomFrom ?? null, event.zoomTo ?? null);
} else {
triggerSearchKeepZoom();
}
}}
onzoomchange={(event) => {
triggerSearchWithZoom(event?.zoomFrom ?? null, event?.zoomTo ?? null);
}}
triggerSearchWithZoom(event?.zoomFrom ?? null, event?.zoomTo ?? null);
}}
/>
</div>

View File

@@ -1,218 +0,0 @@
{
"id": null,
"uid": "ollama-dashboard",
"title": "Ollama",
"description": "Ollama inference latency and request rate",
"version": 1,
"schemaVersion": 39,
"tags": ["ollama", "inference"],
"timezone": "browser",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"links": [],
"liveNow": false,
"refresh": "30s",
"time": {
"from": "now-1h",
"to": "now"
},
"timepicker": {},
"weekStart": "",
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": { "type": "datasource", "uid": "grafana" },
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"panels": [
{
"id": 1,
"type": "timeseries",
"title": "Inference Latency p50",
"description": "50th percentile of Ollama request duration over a 5-minute window",
"gridPos": { "h": 8, "w": 8, "x": 0, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "auto",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "red", "value": 80 }
]
},
"unit": "s"
},
"overrides": []
},
"options": {
"legend": { "calcs": ["mean", "max"], "displayMode": "list", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "single", "sort": "none" }
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"editorMode": "code",
"expr": "histogram_quantile(0.5, rate(ollama_request_duration_seconds_bucket[5m]))",
"instant": false,
"legendFormat": "p50",
"range": true,
"refId": "A"
}
]
},
{
"id": 2,
"type": "timeseries",
"title": "Inference Latency p95",
"description": "95th percentile of Ollama request duration over a 5-minute window",
"gridPos": { "h": 8, "w": 8, "x": 8, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "auto",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "red", "value": 80 }
]
},
"unit": "s"
},
"overrides": []
},
"options": {
"legend": { "calcs": ["mean", "max"], "displayMode": "list", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "single", "sort": "none" }
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"editorMode": "code",
"expr": "histogram_quantile(0.95, rate(ollama_request_duration_seconds_bucket[5m]))",
"instant": false,
"legendFormat": "p95",
"range": true,
"refId": "A"
}
]
},
{
"id": 3,
"type": "timeseries",
"title": "Request Rate",
"description": "Ollama requests per second over a 5-minute window",
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "auto",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "red", "value": 80 }
]
},
"unit": "reqps"
},
"overrides": []
},
"options": {
"legend": { "calcs": ["mean", "max"], "displayMode": "list", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "single", "sort": "none" }
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"editorMode": "code",
"expr": "rate(ollama_requests_total[5m])",
"instant": false,
"legendFormat": "req/s",
"range": true,
"refId": "A"
}
]
}
],
"preload": false,
"templating": {
"list": []
}
}

View File

@@ -22,8 +22,3 @@ scrape_configs:
static_configs:
- targets: ['ocr-service:8000']
- job_name: ollama
metrics_path: /metrics
static_configs:
# Uses the Docker service name for reliable DNS resolution.
- targets: ['ollama:11434']