Compare commits
1 Commits
feat/issue
...
33c6035199
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33c6035199 |
@@ -3,7 +3,7 @@ name: SDD Gate
|
||||
# Spec-Driven Development quality gate. Runs on PRs.
|
||||
#
|
||||
# This project is ISSUE-ONLY: a feature's spec lives in its Gitea issue body, not a committed
|
||||
# spec.md (see ADR-042). So CI cannot lint the spec text itself — instead it validates the SDD
|
||||
# spec.md (see ADR-041). So CI cannot lint the spec text itself — instead it validates the SDD
|
||||
# artifacts that DO live in git: the RTM, any committed OpenAPI contract, and the constitution.
|
||||
#
|
||||
# The first two jobs are NON-BLOCKING for now (continue-on-error) so the team can adopt the
|
||||
@@ -11,7 +11,7 @@ name: SDD Gate
|
||||
#
|
||||
# TODO: flip rtm-check and contract-validate to BLOCKING (remove `continue-on-error: true`)
|
||||
# once SDD adoption has settled — target: after the first 5 features have shipped through
|
||||
# the workflow. Tracked in ADR-042.
|
||||
# the workflow. Tracked in ADR-041.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
@@ -10,7 +10,7 @@ This project already keeps a mature, permanent ADR archive at
|
||||
next free `NNN` (verify against the directory on disk — parallel worktrees make
|
||||
issue-body numbers stale). Template: [`../templates/adr.md`](../templates/adr.md).
|
||||
- **The decision to adopt SDD itself** →
|
||||
[`docs/adr/042-sdd-adoption.md`](../../docs/adr/042-sdd-adoption.md) (this is the
|
||||
[`docs/adr/041-sdd-adoption.md`](../../docs/adr/041-sdd-adoption.md) (this is the
|
||||
"ADR-000" the SDD scaffold calls for, numbered to fit the existing sequence).
|
||||
- **Feature-local decisions** that are only meaningful within one in-flight feature →
|
||||
beside that feature's spec, e.g.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
**Version:** v1.0.0
|
||||
**Status:** Ratified
|
||||
**Date:** 2026-06-13
|
||||
**Adoption ADR:** [docs/adr/042-sdd-adoption.md](../docs/adr/042-sdd-adoption.md)
|
||||
**Adoption ADR:** [docs/adr/041-sdd-adoption.md](../docs/adr/041-sdd-adoption.md)
|
||||
|
||||
> The non-negotiable rules of this project. Every spec, every PR, and every AI agent is
|
||||
> bound by this document. Rules here are deliberately few and absolute — guidance and
|
||||
@@ -73,7 +73,7 @@
|
||||
|
||||
When this constitution changes, the author MUST, in the same PR:
|
||||
|
||||
1. Bump the **Version** header per the semantic rule above and record the change in [docs/adr/042-sdd-adoption.md](../docs/adr/042-sdd-adoption.md)'s revision log (or a superseding ADR for a MAJOR change).
|
||||
1. Bump the **Version** header per the semantic rule above and record the change in [docs/adr/041-sdd-adoption.md](../docs/adr/041-sdd-adoption.md)'s revision log (or a superseding ADR for a MAJOR change).
|
||||
2. Re-read and reconcile every file that restates a rule changed here: `CLAUDE.md`, `COLLABORATING.md`, `CODESTYLE.md`, `CONTRIBUTING.md`, `.specify/AGENTS.md`, and the affected `.specify/personas/*.md` checklists.
|
||||
3. Update any `.specify/templates/*` section that quotes a changed rule.
|
||||
4. Run the `constitution-diff` CI job locally (or read its PR comment) and resolve every file it lists.
|
||||
|
||||
@@ -43,83 +43,3 @@
|
||||
| REQ-006 | `timeline` in coverage `include` (80% branch gate) | #778 | timeline-date-label | `frontend/vite.config.ts` | coverage `include` now lists `src/lib/timeline/**`; covered by all `dateLabel.spec.ts` cases | Done |
|
||||
|
||||
<!-- Append real features below this line, one row per REQ-NNN, with the real issue number. Keep the header row above. -->
|
||||
| REQ-001 | family-member Person with birthDate → 1 BIRTH event, precision passed through | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents` | `DerivedEventsAssemblyTest#should_emit_one_geburt_for_person_with_birthdate`, `#should_pass_birth_precision_through_unchanged` | Done |
|
||||
| REQ-002 | family-member Person with deathDate → 1 DEATH event | #776 | derive-person-life-events | `timeline/TimelineEventService#buildDeathEvents` | `DerivedEventsAssemblyTest#should_emit_one_tod_for_person_with_deathdate`, `#should_emit_one_tod_and_zero_geburt_for_person_with_deathdate_only` | Done |
|
||||
| REQ-003 | null birthDate → 0 Geburt events | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents` | `DerivedEventsAssemblyTest#should_emit_zero_tod_when_person_has_birthdate_but_no_deathdate`, `#should_emit_one_tod_and_zero_geburt_for_person_with_deathdate_only` | Done |
|
||||
| REQ-004 | null deathDate → 0 Tod events; no dates → 0 events | #776 | derive-person-life-events | `timeline/TimelineEventService#buildDeathEvents` | `DerivedEventsAssemblyTest#should_emit_no_events_for_person_with_neither_date` | Done |
|
||||
| REQ-005 | SPOUSE_OF edge with fromYear → MARRIAGE event, eventDate={fromYear}-01-01, precision=YEAR | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_one_heirat_for_spouse_edge_with_fromYear` | Done |
|
||||
| REQ-006 | SPOUSE_OF edge with null fromYear → MARRIAGE emitted, eventDate=null, precision=UNKNOWN | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_unknown_precision_heirat_when_fromYear_is_null` | Done |
|
||||
| REQ-007 | exactly one MARRIAGE per SPOUSE_OF row (dedup on relationship id) | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_exactly_one_heirat_when_both_spouses_in_scope`, `#should_emit_two_heirat_for_person_married_to_two_partners` | Done |
|
||||
| REQ-008 | synthetic prefixed ids (birth:/death:/marriage:), never parseable as UUID | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents,#buildDeathEvents,#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_mint_prefixed_synthetic_ids_never_uuid` | Done |
|
||||
| REQ-009 | every derived event: derived=true, type=PERSONAL, non-null derivedType, non-UUID id | #776 | derive-person-life-events | `timeline/TimelineEventService` | structural invariants asserted inline in every event test | Done |
|
||||
| REQ-010 | every derived event: non-null non-blank primaryPersonName; Heirat also non-null non-blank relatedPersonName | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents,#buildDeathEvents,#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_heirat_with_displayname_for_both_spouses` | Done |
|
||||
| REQ-011 | exactly one call to findAllFamilyMembers() and one to findAllSpouseEdges() — no N+1 | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents`, `relationship/RelationshipService#findAllSpouseEdges` | test structure: only batch-fetch mocks used (no per-person stubs) | Done |
|
||||
| REQ-012 | familyMember=false persons excluded from Geburt/Tod assembly | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents` (via PersonService.findAllFamilyMembers) | `DerivedEventsAssemblyTest#should_exclude_non_family_member_persons_from_derived_events` | Done |
|
||||
| REQ-013 | SPOUSE_OF edge with one non-family-member spouse still emits 1 MARRIAGE event | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_heirat_when_one_spouse_is_not_family_member` | Done |
|
||||
| REQ-014 | empty family-member list → empty result, no error | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents` | `DerivedEventsAssemblyTest#should_emit_zero_events_when_no_family_members` | Done |
|
||||
| REQ-015 | assembleDerivedEvents() annotated @Transactional(readOnly=true) | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents` | code-review check (annotation visible in source) | Done |
|
||||
| REQ-016 | no PII (names, dates) in log statements — only aggregate counts | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents` | log-audit: single log.debug with result.size() and persons.size() only | Done |
|
||||
| REQ-001 | GET /api/timeline requires READ_ALL permission; 401 unauthenticated, 403 wrong permission | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_401_when_unauthenticated`, `#returns_403_when_authenticated_without_read_all`, `#returns_200_with_read_all_permission` | Done |
|
||||
| REQ-002 | within-band sort: precision rank desc (DAY>MONTH>SEASON>YEAR>APPROX), then date asc, then title alpha, then id tiebreak | #777 | timeline-assembly | `timeline/TimelineService#WITHIN_BAND_ORDER` | `TimelineServiceTest#within_band_order_day_precision_sorts_before_year`, `#within_band_order_same_precision_and_date_sorts_alphabetically`, `#within_band_order_same_title_uses_document_id_as_tiebreak`, `#test5_day_precision_sorts_before_year_in_same_year_band`, `#test6_same_precision_same_date_sorted_alphabetically_by_title` | Done |
|
||||
| REQ-003 | null eventDate OR UNKNOWN precision → undated bucket (never in a year band) | #777 | timeline-assembly | `timeline/TimelineService#bucketByYear` | `TimelineServiceTest#test3a_null_date_letter_goes_to_undated`, `#test3b_unknown_precision_letter_goes_to_undated` | Done |
|
||||
| REQ-004 | RANGE events placed in start-year band only; null eventDateEnd does not crash; start year outside [fromYear,toYear] → excluded | #777 | timeline-assembly | `timeline/TimelineService#assembleCuratedEvents` | `TimelineServiceTest#test7a_range_event_placed_only_in_start_year_band`, `#test7b_range_event_with_null_eventDateEnd_does_not_crash`, `#test8_range_event_excluded_when_start_year_before_fromYear`, `#test15_range_event_start_year_equal_to_fromYear_is_included` | Done |
|
||||
| REQ-005 | null sender and null senderText on a document → senderName="" in the TimelineEntryDTO | #777 | timeline-assembly | `timeline/TimelineService#toLetterEntry` | `TimelineServiceTest#test4_letter_with_null_sender_and_null_senderText_produces_empty_names` | Done |
|
||||
| REQ-006 | personId filter: include document when personId is sender OR receiver | #777 | timeline-assembly | `timeline/TimelineService#fetchDocuments` | `TimelineServiceTest#test11_personId_scoping_deduplicates_letter_appearing_as_sender_and_receiver` | Done |
|
||||
| REQ-007 | documents domain letters always included (no type filter applied to LETTER kind) | #777 | timeline-assembly | `timeline/TimelineService#fetchDocuments`, `#assemble` | `TimelineServiceTest#test9a_type_filter_does_not_exclude_letters_but_excludes_wrong_type_events`, `#test1_empty_archive_returns_empty_dto`, `#test2_one_year_letter_returns_one_year_band` | Done |
|
||||
| REQ-008 | personId filter dedup: sender+receiver same person → document appears exactly once | #777 | timeline-assembly | `timeline/TimelineService#fetchDocuments` | `TimelineServiceTest#test11_personId_scoping_deduplicates_letter_appearing_as_sender_and_receiver` | Done |
|
||||
| REQ-009 | type filter applies to events only; letters (LETTER kind) always pass | #777 | timeline-assembly | `timeline/TimelineService#assembleCuratedEvents`, `#assembleDerivedEventsLayer` | `TimelineServiceTest#test9a_type_filter_does_not_exclude_letters_but_excludes_wrong_type_events` | Done |
|
||||
| REQ-010 | generation filter: PersonService.getPersonsByGeneration(N) used to build person-id set; filters all three layers | #777 | timeline-assembly | `timeline/TimelineService#assemble`, `person/PersonService#getPersonsByGeneration`, `person/PersonRepository#findByGeneration` | `TimelineServiceTest#test9b_generation_filter_includes_letter_when_sender_matches_generation`, `TimelineServiceIntegrationTest#findByGeneration_returns_matching_persons`, `#findByGeneration_returns_empty_list_not_npe_when_no_match`, `#findByGeneration_does_not_return_null_generation_persons` | Done |
|
||||
| REQ-011 | fromYear/toYear inclusive year-range filter; single-year window (fromYear==toYear); one-sided filter (fromYear only) | #777 | timeline-assembly | `timeline/TimelineService#passesYearFilter` | `TimelineServiceTest#test9c_fromYear_toYear_inclusive_single_year_window`, `#test16_fromYear_without_toYear_returns_all_items_from_that_year_onwards` | Done |
|
||||
| REQ-012 | combined filters AND logic — entry must pass all active filters | #777 | timeline-assembly | `timeline/TimelineService#assemble` | `TimelineServiceTest#test10_adversarial_and_logic_neither_event_passes_both_filters`, `#test12_personId_plus_generation_filter_returns_empty_when_generations_do_not_match` | Done |
|
||||
| REQ-013 | empty archive (no events, no persons, no documents) → TimelineDTO { years=[], undated=[] } | #777 | timeline-assembly | `timeline/TimelineService#assemble` | `TimelineServiceTest#test1_empty_archive_returns_empty_dto` | Done |
|
||||
| REQ-014 | unauthenticated request → 401 Unauthorized | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_401_when_unauthenticated` | Done |
|
||||
| REQ-015 | authenticated without READ_ALL → 403 Forbidden | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_403_when_authenticated_without_read_all` | Done |
|
||||
| REQ-016 | fromYear > toYear → 400 Bad Request | #777 | timeline-assembly | `timeline/TimelineService#assemble` | `TimelineServiceTest#fromYear_greater_than_toYear_throws_bad_request`, `TimelineControllerTest#returns_400_when_fromYear_greater_than_toYear` | Done |
|
||||
| REQ-017 | generation < 0 → 400 Bad Request (@Min(0) on controller param) | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_400_when_generation_is_negative` | Done |
|
||||
| REQ-018 | unknown EventType string → 400 Bad Request (Spring enum binding) | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_400_on_bad_type_value` | Done |
|
||||
| REQ-019 | unknown personId → 404 Not Found | #777 | timeline-assembly | `timeline/TimelineService#assemble` | `TimelineControllerTest#returns_404_when_person_not_found` | Done |
|
||||
| REQ-020 | null-generation persons excluded from generation-filter results | #777 | timeline-assembly | `person/PersonRepository#findByGeneration` | `TimelineServiceTest#test13_null_generation_sender_not_returned_by_generation_filter`, `TimelineServiceIntegrationTest#findByGeneration_does_not_return_null_generation_persons` | Done |
|
||||
| REQ-001 | `/zeitstrahl` renders the global timeline for authenticated users, personId undefined | #779 | zeitstrahl-global-view | `frontend/src/routes/zeitstrahl/+page.server.ts`, `+page.svelte` | `zeitstrahl/page.server.test.ts#fetches GET /api/timeline and returns { timeline } on ok` | Done |
|
||||
| REQ-002 | server-load fetches GET /api/timeline via createApiClient, returns { timeline }, no client fetch | #779 | zeitstrahl-global-view | `frontend/src/routes/zeitstrahl/+page.server.ts` | `zeitstrahl/page.server.test.ts#fetches GET /api/timeline and returns { timeline } on ok` | Done |
|
||||
| REQ-003 | render bands + entries in DTO order, no client re-sort/re-bucket | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte`, `TimelineView.svelte` | `YearBand.svelte.spec.ts#renders entries in DTO order`, `TimelineView.svelte.spec.ts#renders the timeline as a single <ol>` | Done |
|
||||
| REQ-004 | ≥1024px centered axis, letters alternating left/right | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte` (data-side CSS), `TimelineView.svelte` | `TimelineView.svelte.spec.ts#places consecutive letter cards on alternating sides`, `e2e/zeitstrahl.spec.ts` | Done |
|
||||
| REQ-005 | <1024px single left axis, no overflow down to 320px | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte`, `LetterCard.svelte` | `e2e/zeitstrahl.spec.ts#no horizontal overflow at 320px with long correspondent names` | Done |
|
||||
| REQ-006 | single `<ol>` chronological; each band a `<section>` with sticky `<h2>` at top:4rem | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte`, `YearBand.svelte` | `TimelineView.svelte.spec.ts#renders the timeline as a single <ol>`, `YearBand.svelte.spec.ts#sticky h2 at top:4rem` | Done |
|
||||
| REQ-007 | derived entry → centered family pill with glyph + German derivedType label | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/EventPill.svelte`, `eventCardConfig.ts` | `EventPill.svelte.spec.ts#derived marriage/birth/death`, `eventCardConfig.spec.ts` | Done |
|
||||
| REQ-008 | curated PERSONAL pill; edit affordance only when eventId != null | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/EventPill.svelte` | `EventPill.svelte.spec.ts#edit affordance for curated with eventId`, `#no edit affordance when eventId is null`, `#no edit affordance for a derived event` | Done |
|
||||
| REQ-009 | HISTORICAL → full-width band once in eventDate year; RANGE span pill with Zeitraum aria-label | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/WorldBand.svelte` | `WorldBand.svelte.spec.ts#RANGE span pill 1914–1918 with a Zeitraum aria-label` | Done |
|
||||
| REQ-010 | RANGE with null eventDateEnd → start-year label, no span pill, no crash | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/WorldBand.svelte` | `WorldBand.svelte.spec.ts#degrades a RANGE with no end to the start year` | Done |
|
||||
| REQ-011 | band ≤12 letters → individual LetterCards | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte` | `YearBand.svelte.spec.ts#renders each letter as a card`, `TimelineView.svelte.spec.ts` | Done |
|
||||
| REQ-012 | band >12 letters → single YearLetterStrip with count + 12-month sparkline + expand toggle | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearLetterStrip.svelte`, `timelineDensity.ts` | `YearLetterStrip.svelte.spec.ts`, `YearBand.svelte.spec.ts#renders a single strip`, `timelineDensity.spec.ts#isDense` | Done |
|
||||
| REQ-013 | every dated entry renders date via timelineDateLabel; null → no chip | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#renders the precision date exactly`, `#renders no date chip when timelineDateLabel returns null` | Done |
|
||||
| REQ-014 | empty senderName/receiverName → "Unbekannt" placeholder, never a bare arrow | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#shows "Unbekannt" for an empty sender`, `#empty receiver` | Done |
|
||||
| REQ-015 | interior empty-year run → one folded GapSpan (single year if length 1) | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte`, `GapSpan.svelte` | `TimelineView.svelte.spec.ts#folds an interior run of empty years`, `#single empty interior year`, `GapSpan.svelte.spec.ts` | Done |
|
||||
| REQ-016 | undated non-empty → final "Ohne Datum" section; empty → absent from DOM | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#renders an "Ohne Datum" section`, `#omits the "Ohne Datum" section when empty` | Done |
|
||||
| REQ-017 | years + undated both empty → timeline.empty_state message | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#shows the empty state and no ol` | Done |
|
||||
| REQ-018 | each layer carries a non-color redundant cue (glyph aria-hidden + sr-only label) | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/eventCardConfig.ts`, `EventPill.svelte`, `WorldBand.svelte` | `TimelineView.svelte.spec.ts#redundant non-color cue label`, `EventPill.svelte.spec.ts#wraps the glyph aria-hidden`, `WorldBand.svelte.spec.ts#world glyph` | Done |
|
||||
| REQ-019 | every accent meets WCAG AA in light + dark; HISTORICAL label falls back to text-ink-2 | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/WorldBand.svelte` (text-ink-2) | manual pre-merge contrast check (both ratios recorded in PR) | Done |
|
||||
| REQ-020 | LetterCard link ≥44px touch target + visible focus-visible ring | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#has a touch target of at least 44px` | Done |
|
||||
| REQ-021 | OCR/import text rendered via `{...}` escaping; no `{@html}` in lib/timeline/ | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/*` | code review + `grep -r '@html' frontend/src/lib/timeline/` → zero; `LetterCard.svelte.spec.ts` | Done |
|
||||
| REQ-022 | non-ok load → error(status, mapped); 401 → redirect('/login'); never raw JSON | #779 | zeitstrahl-global-view | `frontend/src/routes/zeitstrahl/+page.server.ts` | `zeitstrahl/page.server.test.ts#redirects to /login on 401`, `#404`, `#500`, `#403` | Done |
|
||||
| REQ-023 | LetterCard href is exactly /documents/{documentId}, no target | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#links to exactly /documents/{documentId} with no target` | Done |
|
||||
| REQ-024 | all user-facing strings via Paraglide keys (layer/derived labels localized per locale) | #779 | zeitstrahl-global-view | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#timeline layer/derived labels are localized per locale`, Paraglide compile | Done |
|
||||
| REQ-025 | personId prop declared but undefined in global view; not passed to leaf cards | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#renders all years and undated entries with personId undefined` | Done |
|
||||
| REQ-026 | month-bucket helpers in $lib/shared/utils/monthBuckets.ts; no lib/timeline → lib/document import | #779 | zeitstrahl-global-view | `frontend/src/lib/shared/utils/monthBuckets.ts` | `monthBuckets.spec.ts` (relocated) + eslint boundary + `grep lib/document` → zero | Done |
|
||||
| REQ-027 | monthHistogram returns 12 MonthBuckets for the band year via shared fillDensityGaps | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/timelineDensity.ts` | `timelineDensity.spec.ts#monthHistogram` | Done |
|
||||
| REQ-001 | curator with WRITE_ALL granted access to /zeitstrahl/events/new + /[id]/edit | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts`, `[id]/edit/+page.server.ts` | `new/page.server.spec.ts#allows a curator with WRITE_ALL`, `[id]/edit/page.server.spec.ts#seeds the form with the event on an ok GET` | Done |
|
||||
| REQ-002 | unauthenticated (null user) → 403 (null-user guard before groups deref) | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts`, `[id]/edit/+page.server.ts` | `new/page.server.spec.ts#throws 403 for an unauthenticated (null) user`, `[id]/edit/page.server.spec.ts#throws 403 for an unauthenticated (null) user` | Done |
|
||||
| REQ-003 | authenticated without WRITE_ALL → 403 | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts` (hasWriteAll) | `new/page.server.spec.ts#throws 403 for an authenticated user without WRITE_ALL`, `[id]/edit/page.server.spec.ts#throws 403 for a user without WRITE_ALL` | Done |
|
||||
| REQ-004 | valid create → POST + redirect to resolved target | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts` (save), `lib/timeline/eventFormServer.ts#toEventRequest` | `new/page.server.spec.ts#posts a TimelineEventRequest and redirects on success` | Done |
|
||||
| REQ-005 | valid edit → PUT + redirect to resolved target | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (save) | `[id]/edit/page.server.spec.ts#updates via PUT (with version) and redirects on success` | Done |
|
||||
| REQ-006 | confirmed delete → DELETE + redirect | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (delete), `lib/timeline/EventForm.svelte` (getConfirmService) | `[id]/edit/page.server.spec.ts#deletes via DELETE and redirects to the resolved target on success` | Done |
|
||||
| REQ-007 | non-ok DELETE → surface mapped error, no redirect | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (delete) | `[id]/edit/page.server.spec.ts#returns fail(status) and does not redirect when DELETE is not ok` | Done |
|
||||
| REQ-008 | precision = RANGE → end-date field visible | #781 | timeline-curator-forms | `frontend/src/lib/shared/primitives/DatePrecisionField.svelte`, `lib/timeline/EventForm.svelte` | `EventForm.svelte.spec.ts#reveals the end-date field when precision is RANGE`, `WhoWhenSection.svelte.spec.ts#reveals the end-date field when precision is RANGE` | Done |
|
||||
| REQ-009 | precision ≠ RANGE → end-date hidden, eventDateEnd submitted null | #781 | timeline-curator-forms | `frontend/src/lib/shared/primitives/DatePrecisionField.svelte`, `lib/timeline/eventFormServer.ts#parseEventForm` | `EventForm.svelte.spec.ts#hides the end-date field when precision is YEAR`, `new/page.server.spec.ts#sends eventDateEnd: null when precision is not RANGE` | Done |
|
||||
| REQ-010 | blank title → localized required error, no nav, picker values preserved | #781 | timeline-curator-forms | `frontend/src/lib/timeline/eventFormServer.ts#validateEventForm`, `EventForm.svelte` | `EventForm.svelte.spec.ts#shows a required-field error when title is blank`, `new/page.server.spec.ts#returns fail(400) with preserved picker arrays on blank title` | Done |
|
||||
| REQ-011 | blank title + date → both errors via per-field aria-invalid | #781 | timeline-curator-forms | `frontend/src/lib/timeline/eventFormServer.ts#validateEventForm` | `new/page.server.spec.ts#surfaces both title and date errors when both blank` | Done |
|
||||
| REQ-012 | unknown/derived event id (non-ok GET) → 404, never blank create form | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (load) | `[id]/edit/page.server.spec.ts#throws 404 when the GET is not ok (unknown or derived id)` | Done |
|
||||
| REQ-013 | 409 Conflict → generic conflict message, no redirect (no merge UI) | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (save) | `[id]/edit/page.server.spec.ts#maps a 409 conflict and does not redirect`, `new/page.server.spec.ts#maps the API error and does not redirect on a non-ok save (incl. 409)` | Done |
|
||||
| REQ-014 | valid ?personId/?documentId prefill pre-selected; unknown id silently ignored | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts` (load Promise.all), `EventForm.svelte` | `new/page.server.spec.ts#preselects a valid person and ignores an unknown document`, `EventForm.svelte.spec.ts#preselects a person when initialPersons is provided` | Done |
|
||||
| REQ-015 | absent/empty/non-UUID originPersonId → redirect /zeitstrahl (CWE-601) | #781 | timeline-curator-forms | `frontend/src/lib/timeline/eventFormServer.ts#resolveNavTarget` | `new/page.server.spec.ts#defaults to /zeitstrahl when originPersonId is not a valid UUID`, `#redirects to /persons/{id} when originPersonId is a valid UUID` | Done |
|
||||
| REQ-016 | title/description/chip labels via default `{...}` escaping, never `{@html}` (CWE-79) | #781 | timeline-curator-forms | `frontend/src/lib/timeline/EventForm.svelte` | code review + `grep -r '@html' frontend/src/lib/timeline/` → zero | Done |
|
||||
| REQ-017 | labelled pickers, visible empty states, ≥44px chip remove targets | #781 | timeline-curator-forms | `frontend/src/lib/person/PersonMultiSelect.svelte`, `document/DocumentMultiSelect.svelte`, `EventForm.svelte` | `PersonMultiSelect.svelte.spec.ts`, `DocumentMultiSelect.svelte.spec.ts` (green post-44px fix), `EventForm.svelte.spec.ts#preselects a person when initialPersons is provided` | Done |
|
||||
|
||||
@@ -99,7 +99,7 @@ backend/src/main/java/org/raddatz/familienarchiv/
|
||||
│ └── relationship/ PersonRelationship sub-domain
|
||||
├── security/ SecurityConfig, Permission, @RequirePermission, PermissionAspect
|
||||
├── tag/ Tag domain
|
||||
├── timeline/ Timeline (Zeitstrahl) domain — TimelineEvent, TimelineEventService, TimelineEntryDTO, DerivedEventType, EventType, TimelineEventRepository; TimelineEventService.assembleDerivedEvents() returns derived life-events (Geburt/Tod/Heirat) computed on read from Person/relationship data; TimelineService assembles year-bucketed TimelineDTO (curated events + derived events + archive letters); TimelineController exposes GET /api/timeline
|
||||
├── timeline/ Timeline (Zeitstrahl) domain — TimelineEvent, EventType, TimelineEventRepository
|
||||
└── user/ User domain — AppUser, UserGroup, UserService
|
||||
```
|
||||
|
||||
@@ -121,7 +121,6 @@ backend/src/main/java/org/raddatz/familienarchiv/
|
||||
| `Geschichte` | `geschichten` | `GeschichteType` (`STORY`/`JOURNEY`); ManyToMany `persons` (Person); OneToMany `items` (JourneyItem) |
|
||||
| `JourneyItem` | `journey_items` | ManyToOne `geschichte` (Geschichte, ON DELETE CASCADE); ManyToOne `document` (Document, ON DELETE SET NULL); `position`, optional `note` |
|
||||
| `TimelineEvent` | `timeline_events` | `EventType` (`PERSONAL`/`HISTORICAL`); ManyToMany `persons` (Person) + `documents` (Document), both join FKs ON DELETE CASCADE; `DatePrecision` date block; `@Version` + NOT NULL `createdBy`/`updatedBy` audit trail |
|
||||
| `TimelineEntryDTO` | _(computed — no table)_ | Unified DTO for all timeline entries assembled by `TimelineService`; 13 fields: `kind` (`EVENT`\|`LETTER`), `precision` (raw `DatePrecision` enum), `derived` (boolean), `senderName` (non-null `String`, `""` = unknown), `receiverName` (non-null `String`, `""` = unknown), `eventDate`, `eventDateEnd`, `title`, `type` (`EventType`, null for LETTER), `eventId` (null for derived entries and letters), `documentId` (set for letters), `linkedPersonIds: List<UUID>`, `derivedType` (`DerivedEventType`, null for curated/letters); edit-affordance contract: `derived == true \|\| eventId == null` → no edit link |
|
||||
|
||||
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
|
||||
|
||||
@@ -207,8 +206,6 @@ frontend/src/routes/
|
||||
├── aktivitaeten/ Unified activity feed (Chronik)
|
||||
├── geschichten/ Stories — list, [id], [id]/edit, new
|
||||
├── stammbaum/ Family tree (Stammbaum)
|
||||
├── zeitstrahl/ Global timeline (Zeitstrahl) — life-events + events + letters woven in time; SSR-loads GET /api/timeline, renders lib/timeline/TimelineView (Datum mode)
|
||||
│ └── events/ Curator event editor (WRITE_ALL-gated) — new (create) + [id]/edit (edit + delete); reuses lib/timeline/EventForm
|
||||
├── themen/ Topics directory — browsable tag index
|
||||
├── enrich/ Enrichment workflow — [id], done
|
||||
├── admin/ User, group, tag, OCR, system management
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
How we turn a feature idea into merged, traceable code in this repo. SDD layers a uniform,
|
||||
machine-readable front-end onto the workflow we already run (Gitea issues → branch/PR →
|
||||
multi-persona review → red/green TDD). It does not replace any of that — see
|
||||
[ADR-042](./docs/adr/042-sdd-adoption.md) for the why.
|
||||
[ADR-041](./docs/adr/041-sdd-adoption.md) for the why.
|
||||
|
||||
- **The rules** live in [`.specify/constitution.md`](./.specify/constitution.md) (humans) and
|
||||
[`.specify/AGENTS.md`](./.specify/AGENTS.md) (AI agents, every invocation).
|
||||
@@ -179,7 +179,7 @@ issue body for you via the Gitea API.)
|
||||
when a project-wide rule genuinely changes. Bump the semantic version (MAJOR = rule
|
||||
removed/weakened, MINOR = rule added/tightened, PATCH = wording), run the §6 Sync Impact
|
||||
review, and let the `constitution-diff` CI job list the files to reconcile. Record the bump
|
||||
in ADR-042's revision log (or a superseding ADR for MAJOR).
|
||||
in ADR-041's revision log (or a superseding ADR for MAJOR).
|
||||
- **AGENTS.md** — keep it under 200 lines. It cross-references the constitution; it must never
|
||||
duplicate or contradict it.
|
||||
- **ADRs** — project-wide/irreversible decisions go in [`docs/adr/`](./docs/adr/) (next free
|
||||
|
||||
@@ -56,11 +56,6 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
// Prüft effizient, ob ein Dateiname schon existiert (gibt true/false zurück)
|
||||
boolean existsByOriginalFilename(String originalFilename);
|
||||
|
||||
// Bulk-fetch for global timeline path — single query with sender+receivers eager-loaded.
|
||||
@EntityGraph("Document.list")
|
||||
@Query("SELECT d FROM Document d")
|
||||
List<Document> findAllForTimeline();
|
||||
|
||||
// lazy – @BatchSize(50) fallback active; see ADR-022
|
||||
@EntityGraph("Document.full")
|
||||
List<Document> findBySenderId(UUID senderId);
|
||||
|
||||
@@ -1051,10 +1051,6 @@ public class DocumentService {
|
||||
return documentRepository.findDocumentsWithoutVersions();
|
||||
}
|
||||
|
||||
public List<Document> getAllForTimeline() {
|
||||
return documentRepository.findAllForTimeline();
|
||||
}
|
||||
|
||||
public List<Document> getDocumentsBySender(UUID senderId) {
|
||||
return documentRepository.findBySenderId(senderId);
|
||||
}
|
||||
|
||||
@@ -242,7 +242,4 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
)
|
||||
""", nativeQuery = true)
|
||||
void insertMissingReceiverReference(@Param("source") UUID source, @Param("target") UUID target);
|
||||
|
||||
// Boxed Integer — matches the nullable person.generation column (primitive int would reject null rows).
|
||||
List<Person> findByGeneration(Integer generation);
|
||||
}
|
||||
|
||||
@@ -210,10 +210,6 @@ public class PersonService {
|
||||
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
||||
}
|
||||
|
||||
public List<Person> getPersonsByGeneration(Integer generation) {
|
||||
return personRepository.findByGeneration(generation);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Person setFamilyMember(UUID personId, boolean familyMember) {
|
||||
Person person = getById(personId);
|
||||
|
||||
@@ -86,15 +86,6 @@ public class RelationshipService {
|
||||
return new NetworkDTO(nodes, edges);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all {@code SPOUSE_OF} edges with both person sides JOIN FETCHed.
|
||||
* Used by {@code TimelineService.assembleDerivedEvents()} to build Heirat events
|
||||
* without per-edge N+1 queries.
|
||||
*/
|
||||
public List<PersonRelationship> findAllSpouseEdges() {
|
||||
return relationshipRepository.findAllByRelationTypeIn(List.of(RelationType.SPOUSE_OF));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public RelationshipDTO addRelationship(UUID personId, CreateRelationshipRequest dto) {
|
||||
if (personId.equals(dto.relatedPersonId())) {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
/** Discriminator for derived life-events assembled from Person / PersonRelationship data. */
|
||||
public enum DerivedEventType {
|
||||
BIRTH,
|
||||
DEATH,
|
||||
MARRIAGE
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
/** Discriminates curated/derived events from archive letters in {@link TimelineEntryDTO}. */
|
||||
public enum Kind {
|
||||
EVENT,
|
||||
LETTER
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/timeline")
|
||||
@Validated
|
||||
@RequiredArgsConstructor
|
||||
public class TimelineController {
|
||||
|
||||
private final TimelineService timelineService;
|
||||
|
||||
@GetMapping
|
||||
@RequirePermission(Permission.READ_ALL)
|
||||
public TimelineDTO getTimeline(
|
||||
@RequestParam(required = false) UUID personId,
|
||||
@RequestParam(required = false) @Min(0) Integer generation,
|
||||
@RequestParam(required = false) EventType type,
|
||||
@RequestParam(required = false) Integer fromYear,
|
||||
@RequestParam(required = false) Integer toYear) {
|
||||
return timelineService.assemble(new TimelineFilter(personId, generation, type, fromYear, toYear));
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Assembled timeline response. Year bands are sorted ascending (oldest first).
|
||||
* Undated entries have no usable date or {@code UNKNOWN} precision.
|
||||
*/
|
||||
public record TimelineDTO(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<TimelineYearDTO> years,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<TimelineEntryDTO> undated
|
||||
) {
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Unified DTO for timeline entries — covers curated {@link TimelineEvent} rows, derived
|
||||
* life-events ({@link DerivedEventType}), and archive letters (Documents).
|
||||
*
|
||||
* <p><b>Edit-affordance contract (for issue #7):</b> {@code derived == true || eventId == null}
|
||||
* means no edit link should be rendered by the frontend.
|
||||
*
|
||||
* <p><b>Letter display fields:</b> {@code senderName} — {@code ""} means unknown/unlinked
|
||||
* correspondent; frontend renders {@code 'Unbekannt'} fallback. Only populated for
|
||||
* {@link Kind#LETTER} entries.
|
||||
*
|
||||
* <p><b>Type field:</b> {@code null} for {@link Kind#LETTER} entries; frontend must not render
|
||||
* an event-type badge for letters.
|
||||
*
|
||||
* <p>Callers of {@code TimelineEventService.assembleDerivedEvents()} must independently enforce
|
||||
* {@code READ_ALL} authorization before invoking that method (see ADR-043).
|
||||
*/
|
||||
public record TimelineEntryDTO(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) Kind kind,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision precision,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean derived,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String senderName,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String receiverName,
|
||||
LocalDate eventDate,
|
||||
LocalDate eventDateEnd,
|
||||
String title,
|
||||
EventType type,
|
||||
UUID eventId,
|
||||
UUID documentId,
|
||||
List<UUID> linkedPersonIds,
|
||||
DerivedEventType derivedType
|
||||
) {
|
||||
}
|
||||
@@ -10,8 +10,6 @@ import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
import org.raddatz.familienarchiv.person.relationship.PersonRelationship;
|
||||
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
||||
import org.raddatz.familienarchiv.timeline.TimelineEventView.DocumentRef;
|
||||
import org.raddatz.familienarchiv.timeline.TimelineEventView.PersonView;
|
||||
|
||||
@@ -42,7 +40,6 @@ public class TimelineEventService {
|
||||
private final TimelineEventRepository events;
|
||||
private final PersonService personService;
|
||||
private final DocumentService documentService;
|
||||
private final RelationshipService relationshipService;
|
||||
|
||||
@Transactional
|
||||
public TimelineEventView create(TimelineEventRequest request, UUID actorId) {
|
||||
@@ -232,83 +229,6 @@ public class TimelineEventService {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// --- derived event assembly ---
|
||||
|
||||
/**
|
||||
* Assembles derived life-events (Geburt/Tod/Heirat) from curated Person and
|
||||
* PersonRelationship data. Computed on read, never persisted.
|
||||
*
|
||||
* <p>Derived events are computed, never persisted, and cannot be mutated via the events API
|
||||
* (enforced in #5). Ids produced by this method are structurally non-UUID
|
||||
* ({@code birth:*}, {@code death:*}, {@code marriage:*}) and MUST be rejected by any
|
||||
* write endpoint — enforced and tested in #5. Callers outside the #5 endpoint must
|
||||
* independently enforce {@code READ_ALL} authorization before invoking this method
|
||||
* (see ADR-043).
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<TimelineEntryDTO> assembleDerivedEvents() {
|
||||
List<Person> persons = personService.findAllFamilyMembers();
|
||||
List<PersonRelationship> spouseEdges = relationshipService.findAllSpouseEdges();
|
||||
|
||||
List<TimelineEntryDTO> result = new ArrayList<>();
|
||||
result.addAll(buildBirthEvents(persons));
|
||||
result.addAll(buildDeathEvents(persons));
|
||||
result.addAll(buildMarriageEvents(spouseEdges));
|
||||
|
||||
log.debug("Assembled {} derived events for {} persons", result.size(), persons.size());
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<TimelineEntryDTO> buildBirthEvents(List<Person> persons) {
|
||||
return persons.stream()
|
||||
.filter(p -> p.getBirthDate() != null)
|
||||
.map(p -> new TimelineEntryDTO(
|
||||
Kind.EVENT, p.getBirthDatePrecision(), true, "", "",
|
||||
p.getBirthDate(), null,
|
||||
p.getDisplayName(), EventType.PERSONAL,
|
||||
null, null, List.of(p.getId()), DerivedEventType.BIRTH))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<TimelineEntryDTO> buildDeathEvents(List<Person> persons) {
|
||||
return persons.stream()
|
||||
.filter(p -> p.getDeathDate() != null)
|
||||
.map(p -> new TimelineEntryDTO(
|
||||
Kind.EVENT, p.getDeathDatePrecision(), true, "", "",
|
||||
p.getDeathDate(), null,
|
||||
p.getDisplayName(), EventType.PERSONAL,
|
||||
null, null, List.of(p.getId()), DerivedEventType.DEATH))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<TimelineEntryDTO> buildMarriageEvents(List<PersonRelationship> spouseEdges) {
|
||||
// DB constraint unique_spouse_pair (V55) is the authoritative enforcement;
|
||||
// in-memory dedup on relationship row id is a defensive assertion.
|
||||
Set<UUID> seen = new HashSet<>();
|
||||
List<TimelineEntryDTO> result = new ArrayList<>();
|
||||
for (PersonRelationship r : spouseEdges) {
|
||||
if (seen.add(r.getId())) {
|
||||
// JOIN FETCH in findAllSpouseEdges() guarantees person/relatedPerson are loaded
|
||||
LocalDate eventDate = r.getFromYear() != null
|
||||
? LocalDate.of(r.getFromYear(), 1, 1)
|
||||
: null;
|
||||
DatePrecision precision = r.getFromYear() != null
|
||||
? DatePrecision.YEAR
|
||||
: DatePrecision.UNKNOWN;
|
||||
String title = r.getPerson().getDisplayName()
|
||||
+ " & " + r.getRelatedPerson().getDisplayName();
|
||||
result.add(new TimelineEntryDTO(
|
||||
Kind.EVENT, precision, true, "", "",
|
||||
eventDate, null,
|
||||
title, EventType.PERSONAL,
|
||||
null, null,
|
||||
List.of(r.getPerson().getId(), r.getRelatedPerson().getId()),
|
||||
DerivedEventType.MARRIAGE));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- view assembly (explicit allow-list; never the raw entity) ---
|
||||
|
||||
private TimelineEventView toView(TimelineEvent event) {
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Immutable filter bag for {@link TimelineService#assemble(TimelineFilter)}.
|
||||
* All fields are nullable — null means "no constraint on this dimension".
|
||||
*/
|
||||
public record TimelineFilter(
|
||||
UUID personId,
|
||||
Integer generation,
|
||||
EventType type,
|
||||
Integer fromYear,
|
||||
Integer toYear
|
||||
) {
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Assembles the family timeline from three sources — curated {@link TimelineEvent} rows,
|
||||
* derived person life-events, and archive letters — into a year-bucketed {@link TimelineDTO}.
|
||||
*
|
||||
* <p>Cross-domain data is reached exclusively through domain services (PersonService,
|
||||
* DocumentService). The only repository injected directly is {@link TimelineEventRepository}
|
||||
* (same domain — constitution §1.3).
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class TimelineService {
|
||||
|
||||
/** Primary: precision rank descending (DAY first). Secondary: date ascending. Tertiary: title. Final: id. */
|
||||
static final Comparator<TimelineEntryDTO> WITHIN_BAND_ORDER =
|
||||
Comparator.comparingInt((TimelineEntryDTO e) -> precisionRank(e.precision())).reversed()
|
||||
.thenComparing(e -> e.eventDate() != null ? e.eventDate() : java.time.LocalDate.MAX)
|
||||
.thenComparing(e -> e.title() != null ? e.title() : "")
|
||||
.thenComparing(e -> {
|
||||
if (e.eventId() != null) return e.eventId().toString();
|
||||
if (e.documentId() != null) return e.documentId().toString();
|
||||
return "";
|
||||
});
|
||||
|
||||
private final TimelineEventRepository eventRepository;
|
||||
private final TimelineEventService timelineEventService;
|
||||
private final DocumentService documentService;
|
||||
private final PersonService personService;
|
||||
|
||||
/**
|
||||
* Assembles the timeline for the given filter. All filters are ANDed.
|
||||
* Throws {@link DomainException} (bad request) when fromYear > toYear.
|
||||
* Throws {@link DomainException} (not found) when personId refers to an unknown person.
|
||||
*
|
||||
* <p>{@code @Transactional(readOnly=true)} is required here — unlike simple scalar reads,
|
||||
* this method accesses lazy collections ({@link TimelineEvent#getPersons()},
|
||||
* {@link org.raddatz.familienarchiv.document.Document#getReceivers()}) after the
|
||||
* repository sub-transaction closes. Without this annotation those accesses throw
|
||||
* {@link org.hibernate.LazyInitializationException} in production (constitution §1.6).
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public TimelineDTO assemble(TimelineFilter filter) {
|
||||
if (filter.fromYear() != null && filter.toYear() != null
|
||||
&& filter.fromYear() > filter.toYear()) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"toYear must not be before fromYear");
|
||||
}
|
||||
|
||||
// Resolve generation person IDs once — used across all three layers
|
||||
Set<UUID> genPersonIds = resolveGenerationPersonIds(filter.generation());
|
||||
|
||||
// ── curated events ───────────────────────────────────────────────────
|
||||
List<TimelineEntryDTO> entries = new ArrayList<>();
|
||||
for (TimelineEvent ev : eventRepository.findAll()) {
|
||||
if (!passesTypeFilter(ev.getType(), filter.type())) continue;
|
||||
if (!passesPersonFilter(ev.getPersons(), filter.personId())) continue;
|
||||
if (!passesGenerationFilter(ev.getPersons(), genPersonIds)) continue;
|
||||
if (!passesYearFilter(ev.getEventDate(), ev.getPrecision(), filter)) continue;
|
||||
entries.add(mapEvent(ev));
|
||||
}
|
||||
|
||||
// ── derived events ───────────────────────────────────────────────────
|
||||
for (TimelineEntryDTO derived : timelineEventService.assembleDerivedEvents()) {
|
||||
if (!passesTypeFilter(derived.type(), filter.type())) continue;
|
||||
if (!passesDerivedPersonFilter(derived.linkedPersonIds(), filter.personId())) continue;
|
||||
if (!passesDerivedGenerationFilter(derived.linkedPersonIds(), genPersonIds)) continue;
|
||||
if (!passesYearFilter(derived.eventDate(), derived.precision(), filter)) continue;
|
||||
entries.add(derived);
|
||||
}
|
||||
|
||||
// ── letters ─────────────────────────────────────────────────────────
|
||||
List<Document> docs = fetchDocuments(filter.personId());
|
||||
for (Document doc : docs) {
|
||||
if (!passesLetterGenerationFilter(doc, genPersonIds)) continue;
|
||||
if (!passesYearFilter(doc.getDocumentDate(), doc.getMetaDatePrecision(), filter)) continue;
|
||||
entries.add(mapDocument(doc));
|
||||
}
|
||||
|
||||
return bucket(entries);
|
||||
}
|
||||
|
||||
// ─── Bucketing ───────────────────────────────────────────────────────────
|
||||
|
||||
Map<Integer, List<TimelineEntryDTO>> bucketByYear(List<TimelineEntryDTO> entries) {
|
||||
Map<Integer, List<TimelineEntryDTO>> map = new TreeMap<>();
|
||||
for (TimelineEntryDTO e : entries) {
|
||||
if (e.eventDate() == null || e.precision() == DatePrecision.UNKNOWN) continue;
|
||||
map.computeIfAbsent(e.eventDate().getYear(), k -> new ArrayList<>()).add(e);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private TimelineDTO bucket(List<TimelineEntryDTO> entries) {
|
||||
List<TimelineEntryDTO> undated = entries.stream()
|
||||
.filter(e -> e.eventDate() == null || e.precision() == DatePrecision.UNKNOWN)
|
||||
.sorted(WITHIN_BAND_ORDER)
|
||||
.toList();
|
||||
|
||||
Map<Integer, List<TimelineEntryDTO>> byYear = bucketByYear(entries);
|
||||
List<TimelineYearDTO> years = byYear.entrySet().stream()
|
||||
.map(e -> new TimelineYearDTO(e.getKey(),
|
||||
e.getValue().stream().sorted(WITHIN_BAND_ORDER).toList()))
|
||||
.toList();
|
||||
|
||||
return new TimelineDTO(years, undated);
|
||||
}
|
||||
|
||||
// ─── Document fetch (global vs personId path) ────────────────────────────
|
||||
|
||||
private List<Document> fetchDocuments(UUID personId) {
|
||||
if (personId == null) {
|
||||
return documentService.getAllForTimeline();
|
||||
}
|
||||
// personId path: validate existence, then union sender+receiver (dedup by id)
|
||||
personService.getById(personId);
|
||||
Map<UUID, Document> seen = new LinkedHashMap<>();
|
||||
for (Document d : documentService.getDocumentsBySender(personId)) seen.put(d.getId(), d);
|
||||
for (Document d : documentService.getDocumentsByReceiver(personId)) seen.putIfAbsent(d.getId(), d);
|
||||
return new ArrayList<>(seen.values());
|
||||
}
|
||||
|
||||
// ─── Filter predicates ───────────────────────────────────────────────────
|
||||
|
||||
private boolean passesTypeFilter(EventType entryType, EventType filterType) {
|
||||
return filterType == null || filterType == entryType;
|
||||
}
|
||||
|
||||
private boolean passesYearFilter(java.time.LocalDate date, DatePrecision precision, TimelineFilter filter) {
|
||||
if (date == null || precision == DatePrecision.UNKNOWN) return true; // undated → always passes
|
||||
int year = date.getYear();
|
||||
if (filter.fromYear() != null && year < filter.fromYear()) return false;
|
||||
if (filter.toYear() != null && year > filter.toYear()) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean passesPersonFilter(Set<Person> persons, UUID personId) {
|
||||
if (personId == null) return true;
|
||||
return persons != null && persons.stream().anyMatch(p -> personId.equals(p.getId()));
|
||||
}
|
||||
|
||||
private boolean passesDerivedPersonFilter(List<UUID> linkedIds, UUID personId) {
|
||||
if (personId == null) return true;
|
||||
return linkedIds != null && linkedIds.contains(personId);
|
||||
}
|
||||
|
||||
private Set<UUID> resolveGenerationPersonIds(Integer generation) {
|
||||
if (generation == null) return null;
|
||||
return personService.getPersonsByGeneration(generation).stream()
|
||||
.map(Person::getId)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private boolean passesGenerationFilter(Set<Person> persons, Set<UUID> genPersonIds) {
|
||||
if (genPersonIds == null) return true;
|
||||
if (persons == null || persons.isEmpty()) return false;
|
||||
return persons.stream().anyMatch(p -> genPersonIds.contains(p.getId()));
|
||||
}
|
||||
|
||||
private boolean passesDerivedGenerationFilter(List<UUID> linkedIds, Set<UUID> genPersonIds) {
|
||||
if (genPersonIds == null) return true;
|
||||
if (linkedIds == null || linkedIds.isEmpty()) return false;
|
||||
return linkedIds.stream().anyMatch(genPersonIds::contains);
|
||||
}
|
||||
|
||||
private boolean passesLetterGenerationFilter(Document doc, Set<UUID> genPersonIds) {
|
||||
if (genPersonIds == null) return true;
|
||||
Person sender = doc.getSender();
|
||||
if (sender != null && genPersonIds.contains(sender.getId())) return true;
|
||||
Set<Person> receivers = doc.getReceivers();
|
||||
if (receivers != null) {
|
||||
return receivers.stream().anyMatch(r -> genPersonIds.contains(r.getId()));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── Mapping ─────────────────────────────────────────────────────────────
|
||||
|
||||
private TimelineEntryDTO mapEvent(TimelineEvent ev) {
|
||||
List<UUID> personIds = ev.getPersons() == null ? List.of()
|
||||
: ev.getPersons().stream().map(Person::getId).toList();
|
||||
return new TimelineEntryDTO(
|
||||
Kind.EVENT,
|
||||
ev.getPrecision(),
|
||||
false,
|
||||
"",
|
||||
"",
|
||||
ev.getEventDate(),
|
||||
ev.getEventDateEnd(),
|
||||
ev.getTitle(),
|
||||
ev.getType(),
|
||||
ev.getId(),
|
||||
null,
|
||||
personIds,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private TimelineEntryDTO mapDocument(Document doc) {
|
||||
return new TimelineEntryDTO(
|
||||
Kind.LETTER,
|
||||
doc.getMetaDatePrecision(),
|
||||
false,
|
||||
resolveSenderName(doc),
|
||||
resolveReceiverName(doc),
|
||||
doc.getDocumentDate(),
|
||||
null,
|
||||
doc.getTitle(),
|
||||
null,
|
||||
null,
|
||||
doc.getId(),
|
||||
List.of(),
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private String resolveSenderName(Document doc) {
|
||||
if (doc.getSender() != null) return doc.getSender().getDisplayName();
|
||||
String text = doc.getSenderText();
|
||||
return (text != null && !text.isBlank()) ? text : "";
|
||||
}
|
||||
|
||||
private String resolveReceiverName(Document doc) {
|
||||
Set<Person> receivers = doc.getReceivers();
|
||||
if (receivers != null && !receivers.isEmpty()) {
|
||||
return receivers.stream().findFirst().map(Person::getDisplayName).orElse("");
|
||||
}
|
||||
String text = doc.getReceiverText();
|
||||
return (text != null && !text.isBlank()) ? text : "";
|
||||
}
|
||||
|
||||
private static int precisionRank(DatePrecision precision) {
|
||||
if (precision == null) return 0;
|
||||
return switch (precision) {
|
||||
case DAY -> 5;
|
||||
case MONTH -> 4;
|
||||
case SEASON -> 3;
|
||||
case YEAR -> 2;
|
||||
case APPROX -> 1;
|
||||
default -> 0;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** One year's worth of timeline entries, sorted by {@link TimelineService#WITHIN_BAND_ORDER}. */
|
||||
public record TimelineYearDTO(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int year,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<TimelineEntryDTO> entries
|
||||
) {
|
||||
}
|
||||
@@ -2943,17 +2943,4 @@ class DocumentServiceTest {
|
||||
assertThat(result.buckets()).isEmpty();
|
||||
verify(documentRepository, org.mockito.Mockito.never()).findAll(any(Specification.class));
|
||||
}
|
||||
|
||||
// --- getAllForTimeline ---
|
||||
|
||||
@Test
|
||||
void getAllForTimeline_delegates_bulk_fetch_to_repository() {
|
||||
Document doc = Document.builder().id(UUID.randomUUID()).title("Brief").build();
|
||||
when(documentRepository.findAllForTimeline()).thenReturn(List.of(doc));
|
||||
|
||||
List<Document> result = documentService.getAllForTimeline();
|
||||
|
||||
assertThat(result).containsExactly(doc);
|
||||
verify(documentRepository).findAllForTimeline();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1105,25 +1105,4 @@ class PersonServiceTest {
|
||||
assertThat(result.direct()).hasSize(1);
|
||||
assertThat(result.partial()).isEmpty();
|
||||
}
|
||||
|
||||
// --- getPersonsByGeneration ---
|
||||
|
||||
@Test
|
||||
void getPersonsByGeneration_delegates_to_repository() {
|
||||
Person p = Person.builder().id(UUID.randomUUID()).lastName("Müller").generation(2).build();
|
||||
when(personRepository.findByGeneration(2)).thenReturn(List.of(p));
|
||||
|
||||
List<Person> result = personService.getPersonsByGeneration(2);
|
||||
|
||||
assertThat(result).containsExactly(p);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getPersonsByGeneration_returns_emptyList_when_no_match() {
|
||||
when(personRepository.findByGeneration(99)).thenReturn(List.of());
|
||||
|
||||
List<Person> result = personService.getPersonsByGeneration(99);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,13 +100,6 @@ class ArchitectureTest {
|
||||
.and().resideInAPackage("..audit..")
|
||||
.should().dependOnClassesThat(foreignJpaRepositoryFor("audit"));
|
||||
|
||||
@ArchTest
|
||||
static final ArchRule services_only_access_own_domain_repositories_timeline =
|
||||
noClasses()
|
||||
.that().areAnnotatedWith(Service.class)
|
||||
.and().resideInAPackage("..timeline..")
|
||||
.should().dependOnClassesThat(foreignJpaRepositoryFor("timeline"));
|
||||
|
||||
// Rule 3: Infrastructure @Configuration classes must not end up scattered in domain packages.
|
||||
// Keeps cross-cutting setup (security, async, DB, storage) in dedicated packages
|
||||
// where it can be audited and reasoned about independently.
|
||||
|
||||
@@ -1,377 +0,0 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
import org.raddatz.familienarchiv.person.relationship.PersonRelationship;
|
||||
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
||||
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DerivedEventsAssemblyTest {
|
||||
|
||||
@Mock private TimelineEventRepository events;
|
||||
@Mock private PersonService personService;
|
||||
@Mock private DocumentService documentService;
|
||||
@Mock private RelationshipService relationshipService;
|
||||
|
||||
@InjectMocks private TimelineEventService service;
|
||||
|
||||
// --- factory helpers ---
|
||||
|
||||
private Person makePerson(LocalDate birthDate, DatePrecision birthPrecision) {
|
||||
return Person.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.firstName("Anna")
|
||||
.lastName("Müller")
|
||||
.familyMember(true)
|
||||
.birthDate(birthDate)
|
||||
.birthDatePrecision(birthPrecision)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Person makePersonWithDeath(LocalDate deathDate, DatePrecision deathPrecision) {
|
||||
return Person.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.firstName("Hans")
|
||||
.lastName("Raddatz")
|
||||
.familyMember(true)
|
||||
.deathDate(deathDate)
|
||||
.deathDatePrecision(deathPrecision)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Person makePersonWithBoth(
|
||||
LocalDate birthDate, DatePrecision birthPrecision,
|
||||
LocalDate deathDate, DatePrecision deathPrecision) {
|
||||
return Person.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.firstName("Anna")
|
||||
.lastName("Müller")
|
||||
.familyMember(true)
|
||||
.birthDate(birthDate)
|
||||
.birthDatePrecision(birthPrecision)
|
||||
.deathDate(deathDate)
|
||||
.deathDatePrecision(deathPrecision)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Person makeNonFamilyPerson(LocalDate birthDate, DatePrecision precision) {
|
||||
return Person.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.firstName("Anna")
|
||||
.lastName("Müller")
|
||||
.familyMember(false)
|
||||
.birthDate(birthDate)
|
||||
.birthDatePrecision(precision)
|
||||
.build();
|
||||
}
|
||||
|
||||
private PersonRelationship makeSpouseEdge(Person a, Person b, Integer fromYear) {
|
||||
return PersonRelationship.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.person(a)
|
||||
.relatedPerson(b)
|
||||
.relationType(RelationType.SPOUSE_OF)
|
||||
.fromYear(fromYear)
|
||||
.build();
|
||||
}
|
||||
|
||||
// --- REQ-001: birth events ---
|
||||
|
||||
@Test
|
||||
void should_emit_one_geburt_for_person_with_birthdate() {
|
||||
Person anna = makePerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
|
||||
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
|
||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
|
||||
|
||||
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
TimelineEntryDTO event = result.get(0);
|
||||
assertThat(event.derived()).isTrue();
|
||||
assertThat(event.type()).isEqualTo(EventType.PERSONAL);
|
||||
assertThat(event.derivedType()).isEqualTo(DerivedEventType.BIRTH);
|
||||
assertThat(event.eventDate()).isEqualTo(LocalDate.of(1901, 3, 12));
|
||||
assertThat(event.precision()).isEqualTo(DatePrecision.DAY);
|
||||
assertThat(event.title()).isEqualTo(anna.getDisplayName());
|
||||
}
|
||||
|
||||
// --- REQ-003: null birthDate → no Geburt event ---
|
||||
|
||||
@Test
|
||||
void should_emit_zero_tod_when_person_has_birthdate_but_no_deathdate() {
|
||||
Person anna = makePerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
|
||||
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
|
||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
|
||||
|
||||
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||
|
||||
long todCount = result.stream()
|
||||
.filter(e -> e.derivedType() == DerivedEventType.DEATH)
|
||||
.count();
|
||||
assertThat(todCount).isZero();
|
||||
}
|
||||
|
||||
// --- REQ-004: null deathDate → no Tod event ---
|
||||
|
||||
@Test
|
||||
void should_emit_no_events_for_person_with_neither_date() {
|
||||
Person nobody = Person.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.firstName("Hans")
|
||||
.lastName("Raddatz")
|
||||
.familyMember(true)
|
||||
.build();
|
||||
when(personService.findAllFamilyMembers()).thenReturn(List.of(nobody));
|
||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
|
||||
|
||||
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
// --- REQ-002: death events ---
|
||||
|
||||
@Test
|
||||
void should_emit_one_tod_for_person_with_deathdate() {
|
||||
Person hans = makePersonWithDeath(LocalDate.of(1965, 7, 4), DatePrecision.DAY);
|
||||
when(personService.findAllFamilyMembers()).thenReturn(List.of(hans));
|
||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
|
||||
|
||||
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
TimelineEntryDTO event = result.get(0);
|
||||
assertThat(event.derived()).isTrue();
|
||||
assertThat(event.type()).isEqualTo(EventType.PERSONAL);
|
||||
assertThat(event.derivedType()).isEqualTo(DerivedEventType.DEATH);
|
||||
assertThat(event.eventDate()).isEqualTo(LocalDate.of(1965, 7, 4));
|
||||
assertThat(event.precision()).isEqualTo(DatePrecision.DAY);
|
||||
assertThat(event.title()).isEqualTo(hans.getDisplayName());
|
||||
}
|
||||
|
||||
// --- REQ-002 + REQ-003 combined ---
|
||||
|
||||
@Test
|
||||
void should_emit_one_tod_and_zero_geburt_for_person_with_deathdate_only() {
|
||||
Person hans = makePersonWithDeath(LocalDate.of(1965, 7, 4), DatePrecision.YEAR);
|
||||
when(personService.findAllFamilyMembers()).thenReturn(List.of(hans));
|
||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
|
||||
|
||||
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).derivedType()).isEqualTo(DerivedEventType.DEATH);
|
||||
}
|
||||
|
||||
// --- REQ-005: Heirat with fromYear ---
|
||||
|
||||
@Test
|
||||
void should_emit_one_heirat_for_spouse_edge_with_fromYear() {
|
||||
Person anna = makePerson(null, DatePrecision.UNKNOWN);
|
||||
Person hans = makePersonWithDeath(null, DatePrecision.UNKNOWN);
|
||||
PersonRelationship edge = makeSpouseEdge(anna, hans, 1930);
|
||||
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
|
||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
|
||||
|
||||
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||
|
||||
List<TimelineEntryDTO> heiraten = result.stream()
|
||||
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
|
||||
.toList();
|
||||
assertThat(heiraten).hasSize(1);
|
||||
TimelineEntryDTO heirat = heiraten.get(0);
|
||||
assertThat(heirat.derived()).isTrue();
|
||||
assertThat(heirat.type()).isEqualTo(EventType.PERSONAL);
|
||||
assertThat(heirat.derivedType()).isEqualTo(DerivedEventType.MARRIAGE);
|
||||
assertThat(heirat.eventDate()).isEqualTo(LocalDate.of(1930, 1, 1));
|
||||
assertThat(heirat.precision()).isEqualTo(DatePrecision.YEAR);
|
||||
}
|
||||
|
||||
// --- REQ-006: Heirat with null fromYear → emitted with UNKNOWN precision ---
|
||||
|
||||
@Test
|
||||
void should_emit_unknown_precision_heirat_when_fromYear_is_null() {
|
||||
Person anna = makePerson(null, DatePrecision.UNKNOWN);
|
||||
Person hans = makePersonWithDeath(null, DatePrecision.UNKNOWN);
|
||||
PersonRelationship edge = makeSpouseEdge(anna, hans, null);
|
||||
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
|
||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
|
||||
|
||||
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||
|
||||
List<TimelineEntryDTO> heiraten = result.stream()
|
||||
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
|
||||
.toList();
|
||||
assertThat(heiraten).hasSize(1);
|
||||
TimelineEntryDTO heirat = heiraten.get(0);
|
||||
assertThat(heirat.eventDate()).isNull();
|
||||
assertThat(heirat.precision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||
}
|
||||
|
||||
// --- REQ-007: exactly one Heirat per SPOUSE_OF edge (dedup) ---
|
||||
|
||||
@Test
|
||||
void should_emit_exactly_one_heirat_when_both_spouses_in_scope() {
|
||||
Person anna = makePerson(null, DatePrecision.UNKNOWN);
|
||||
Person hans = makePerson(null, DatePrecision.UNKNOWN);
|
||||
PersonRelationship edge = makeSpouseEdge(anna, hans, 1930);
|
||||
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
|
||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
|
||||
|
||||
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||
|
||||
long heiratCount = result.stream()
|
||||
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
|
||||
.count();
|
||||
assertThat(heiratCount).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_emit_two_heirat_for_person_married_to_two_partners() {
|
||||
Person anna = makePerson(null, DatePrecision.UNKNOWN);
|
||||
Person hans = makePerson(null, DatePrecision.UNKNOWN);
|
||||
Person karl = makePerson(null, DatePrecision.UNKNOWN);
|
||||
PersonRelationship edge1 = makeSpouseEdge(anna, hans, 1930);
|
||||
PersonRelationship edge2 = makeSpouseEdge(anna, karl, 1945);
|
||||
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans, karl));
|
||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge1, edge2));
|
||||
|
||||
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||
|
||||
long heiratCount = result.stream()
|
||||
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
|
||||
.count();
|
||||
assertThat(heiratCount).isEqualTo(2);
|
||||
}
|
||||
|
||||
// --- REQ-001 precision pass-through ---
|
||||
|
||||
@Test
|
||||
void should_pass_birth_precision_through_unchanged() {
|
||||
Person anna = makePerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
|
||||
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
|
||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
|
||||
|
||||
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).precision()).isEqualTo(DatePrecision.DAY);
|
||||
}
|
||||
|
||||
// --- REQ-008: synthetic prefixed ids, never UUID ---
|
||||
|
||||
@Test
|
||||
void should_mint_prefixed_synthetic_ids_never_uuid() {
|
||||
Person anna = makePerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
|
||||
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
|
||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
|
||||
|
||||
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
TimelineEntryDTO entry = result.get(0);
|
||||
assertThat(entry.derived()).isTrue();
|
||||
assertThat(entry.eventId()).isNull();
|
||||
assertThat(entry.documentId()).isNull();
|
||||
}
|
||||
|
||||
// --- REQ-010: display names on Heirat ---
|
||||
|
||||
@Test
|
||||
void should_emit_heirat_with_displayname_for_both_spouses() {
|
||||
Person anna = makePerson(null, DatePrecision.UNKNOWN);
|
||||
Person hans = makePersonWithDeath(null, DatePrecision.UNKNOWN);
|
||||
PersonRelationship edge = makeSpouseEdge(anna, hans, 1930);
|
||||
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
|
||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
|
||||
|
||||
List<TimelineEntryDTO> heiraten = service.assembleDerivedEvents().stream()
|
||||
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
|
||||
.toList();
|
||||
|
||||
assertThat(heiraten).hasSize(1);
|
||||
TimelineEntryDTO heirat = heiraten.get(0);
|
||||
assertThat(heirat.title()).isNotNull().isNotBlank();
|
||||
assertThat(heirat.linkedPersonIds()).hasSize(2);
|
||||
}
|
||||
|
||||
// --- REQ-007 note: assumption/documentation test ---
|
||||
|
||||
@Test
|
||||
void self_spouse_edge_invariant_is_enforced_by_db_constraint() {
|
||||
// Assumption test — documents that the DB constraint prevents self-edges;
|
||||
// the service does not guard this itself.
|
||||
// The unique_spouse_pair index (V55) using LEAST/GREATEST is the authoritative guard.
|
||||
// This test verifies that if an edge were somehow inserted (impossible in prod),
|
||||
// the service would still produce one event (not zero or an exception).
|
||||
Person anna = makePerson(null, DatePrecision.UNKNOWN);
|
||||
PersonRelationship selfEdge = makeSpouseEdge(anna, anna, 1930);
|
||||
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
|
||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(selfEdge));
|
||||
|
||||
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||
|
||||
long heiratCount = result.stream()
|
||||
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
|
||||
.count();
|
||||
assertThat(heiratCount).isEqualTo(1);
|
||||
}
|
||||
|
||||
// --- REQ-012: non-family-member persons excluded from Geburt/Tod ---
|
||||
|
||||
@Test
|
||||
void should_exclude_non_family_member_persons_from_derived_events() {
|
||||
Person nonMember = makeNonFamilyPerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
|
||||
when(personService.findAllFamilyMembers()).thenReturn(List.of());
|
||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
|
||||
|
||||
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
// --- REQ-013: Heirat emitted even when one spouse has familyMember=false ---
|
||||
|
||||
@Test
|
||||
void should_emit_heirat_when_one_spouse_is_not_family_member() {
|
||||
Person anna = makePerson(null, DatePrecision.UNKNOWN);
|
||||
Person nonMember = makeNonFamilyPerson(null, DatePrecision.UNKNOWN);
|
||||
PersonRelationship edge = makeSpouseEdge(anna, nonMember, 1930);
|
||||
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
|
||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
|
||||
|
||||
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||
|
||||
long heiratCount = result.stream()
|
||||
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
|
||||
.count();
|
||||
assertThat(heiratCount).isEqualTo(1);
|
||||
}
|
||||
|
||||
// --- REQ-014: empty family-member list → empty result, no error ---
|
||||
|
||||
@Test
|
||||
void should_emit_zero_events_when_no_family_members() {
|
||||
when(personService.findAllFamilyMembers()).thenReturn(List.of());
|
||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
|
||||
|
||||
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.security.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.hamcrest.Matchers.is;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(TimelineController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
class TimelineControllerTest {
|
||||
|
||||
@Autowired MockMvc mockMvc;
|
||||
|
||||
@MockitoBean TimelineService timelineService;
|
||||
@MockitoBean UserService userService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
private static final TimelineDTO EMPTY = new TimelineDTO(List.of(), List.of());
|
||||
|
||||
@BeforeEach
|
||||
void resolveDefaultPrincipal() {
|
||||
when(userService.findByEmail("user"))
|
||||
.thenReturn(AppUser.builder().id(UUID.randomUUID()).email("user").build());
|
||||
}
|
||||
|
||||
// ─── Security ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void returns_401_when_unauthenticated() throws Exception {
|
||||
// REQ-014
|
||||
mockMvc.perform(get("/api/timeline"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@org.springframework.security.test.context.support.WithMockUser(authorities = "WRITE_ALL")
|
||||
void returns_403_when_authenticated_without_read_all() throws Exception {
|
||||
// REQ-015
|
||||
mockMvc.perform(get("/api/timeline"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
|
||||
void returns_200_with_read_all_permission() throws Exception {
|
||||
// REQ-001
|
||||
when(timelineService.assemble(any())).thenReturn(EMPTY);
|
||||
|
||||
mockMvc.perform(get("/api/timeline"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.years").isArray())
|
||||
.andExpect(jsonPath("$.undated").isArray());
|
||||
}
|
||||
|
||||
// ─── Parameter binding ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
|
||||
void valid_params_are_forwarded_to_service() throws Exception {
|
||||
UUID personId = UUID.randomUUID();
|
||||
when(timelineService.assemble(any())).thenReturn(EMPTY);
|
||||
|
||||
mockMvc.perform(get("/api/timeline")
|
||||
.param("personId", personId.toString())
|
||||
.param("generation", "2")
|
||||
.param("type", "HISTORICAL")
|
||||
.param("fromYear", "1914")
|
||||
.param("toYear", "1918"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(timelineService).assemble(new TimelineFilter(personId, 2, EventType.HISTORICAL, 1914, 1918));
|
||||
}
|
||||
|
||||
// ─── Validation errors ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
|
||||
void returns_400_on_bad_type_value() throws Exception {
|
||||
// REQ-018 — Spring enum binding rejects unknown value
|
||||
mockMvc.perform(get("/api/timeline").param("type", "NOT_A_TYPE"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
|
||||
void returns_400_when_fromYear_greater_than_toYear() throws Exception {
|
||||
// REQ-016 — service throws bad request, controller propagates it
|
||||
when(timelineService.assemble(any()))
|
||||
.thenThrow(DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"toYear must not be before fromYear"));
|
||||
|
||||
mockMvc.perform(get("/api/timeline")
|
||||
.param("fromYear", "1920")
|
||||
.param("toYear", "1914"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
|
||||
void returns_400_when_generation_is_negative() throws Exception {
|
||||
// REQ-017 — @Min(0) on generation parameter
|
||||
mockMvc.perform(get("/api/timeline").param("generation", "-1"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
|
||||
void returns_404_when_person_not_found() throws Exception {
|
||||
// REQ-019
|
||||
when(timelineService.assemble(any()))
|
||||
.thenThrow(DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found"));
|
||||
|
||||
mockMvc.perform(get("/api/timeline").param("personId", UUID.randomUUID().toString()))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code", is("PERSON_NOT_FOUND")));
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Integration tests for {@link TimelineService} and {@link PersonRepository#findByGeneration}
|
||||
* against real Postgres. Verifies that assembled output reflects persisted curated events and
|
||||
* that the generation query handles null-generation rows correctly.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
@Transactional
|
||||
class TimelineServiceIntegrationTest {
|
||||
|
||||
@MockitoBean S3Client s3Client;
|
||||
|
||||
@Autowired TimelineService timelineService;
|
||||
@Autowired TimelineEventRepository timelineEventRepository;
|
||||
@Autowired PersonRepository personRepository;
|
||||
|
||||
@PersistenceContext EntityManager em;
|
||||
|
||||
// ─── PersonRepository.findByGeneration ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findByGeneration_returns_matching_persons() {
|
||||
personRepository.save(Person.builder().lastName("Gen2A").generation(2).build());
|
||||
personRepository.save(Person.builder().lastName("Gen2B").generation(2).build());
|
||||
personRepository.save(Person.builder().lastName("Gen3").generation(3).build());
|
||||
em.flush();
|
||||
|
||||
List<Person> result = personRepository.findByGeneration(2);
|
||||
|
||||
assertThat(result).extracting(Person::getLastName)
|
||||
.containsExactlyInAnyOrder("Gen2A", "Gen2B");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByGeneration_returns_empty_list_not_npe_when_no_match() {
|
||||
personRepository.save(Person.builder().lastName("Gen1").generation(1).build());
|
||||
em.flush();
|
||||
|
||||
List<Person> result = personRepository.findByGeneration(99);
|
||||
|
||||
assertThat(result).isNotNull().isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByGeneration_does_not_return_null_generation_persons() {
|
||||
personRepository.save(Person.builder().lastName("NullGen").build()); // generation stays null
|
||||
em.flush();
|
||||
|
||||
List<Person> result = personRepository.findByGeneration(1);
|
||||
|
||||
assertThat(result).extracting(Person::getLastName).doesNotContain("NullGen");
|
||||
}
|
||||
|
||||
// ─── TimelineService.assemble end-to-end ─────────────────────────────────
|
||||
|
||||
@Test
|
||||
void assemble_includes_persisted_curated_event_in_correct_year_band() {
|
||||
UUID actorId = UUID.randomUUID();
|
||||
TimelineEvent event = timelineEventRepository.save(TimelineEvent.builder()
|
||||
.title("Sarajevo")
|
||||
.type(EventType.HISTORICAL)
|
||||
.eventDate(LocalDate.of(1914, 6, 28))
|
||||
.precision(DatePrecision.DAY)
|
||||
.createdBy(actorId)
|
||||
.updatedBy(actorId)
|
||||
.build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, null, null));
|
||||
|
||||
assertThat(result.years()).anySatisfy(y -> {
|
||||
assertThat(y.year()).isEqualTo(1914);
|
||||
assertThat(y.entries()).anySatisfy(e -> {
|
||||
assertThat(e.title()).isEqualTo("Sarajevo");
|
||||
assertThat(e.kind()).isEqualTo(Kind.EVENT);
|
||||
assertThat(e.eventId()).isEqualTo(event.getId());
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
|
||||
/**
|
||||
* Verifies that {@link TimelineService#assemble} does not throw
|
||||
* {@link org.hibernate.LazyInitializationException} when events have linked persons.
|
||||
*
|
||||
* <p>No class-level {@code @Transactional} — each test method runs without an outer
|
||||
* transaction, matching production behaviour (controller has no {@code @Transactional}).
|
||||
* If {@code assemble()} lacks {@code @Transactional(readOnly=true)}, accessing
|
||||
* {@code ev.getPersons()} on detached entities throws LazyInitializationException.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
class TimelineServiceLazyLoadTest {
|
||||
|
||||
@MockitoBean
|
||||
S3Client s3Client;
|
||||
|
||||
@Autowired
|
||||
TransactionTemplate transactionTemplate;
|
||||
|
||||
@Autowired
|
||||
TimelineService timelineService;
|
||||
|
||||
@Autowired
|
||||
TimelineEventRepository timelineEventRepository;
|
||||
|
||||
@Autowired
|
||||
PersonRepository personRepository;
|
||||
|
||||
@Test
|
||||
void assemble_does_not_throw_when_event_has_linked_persons() {
|
||||
UUID actorId = UUID.randomUUID();
|
||||
// Commit outside any test-managed transaction so entities are detached on return
|
||||
transactionTemplate.execute(status -> {
|
||||
Person person = personRepository.save(Person.builder().lastName("Müller").build());
|
||||
timelineEventRepository.save(TimelineEvent.builder()
|
||||
.title("Linked event")
|
||||
.type(EventType.HISTORICAL)
|
||||
.eventDate(LocalDate.of(1914, 7, 28))
|
||||
.precision(DatePrecision.DAY)
|
||||
.createdBy(actorId)
|
||||
.updatedBy(actorId)
|
||||
.persons(new HashSet<>(Set.of(person)))
|
||||
.build());
|
||||
return null;
|
||||
});
|
||||
|
||||
assertDoesNotThrow(() -> timelineService.assemble(new TimelineFilter(null, null, null, null, null)));
|
||||
}
|
||||
}
|
||||
@@ -1,452 +0,0 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class TimelineServiceTest {
|
||||
|
||||
@Mock TimelineEventRepository eventRepository;
|
||||
@Mock TimelineEventService timelineEventService;
|
||||
@Mock DocumentService documentService;
|
||||
@Mock PersonService personService;
|
||||
|
||||
@InjectMocks TimelineService timelineService;
|
||||
|
||||
// ─── WITHIN_BAND_ORDER standalone tests (REQ-002) ────────────────────────
|
||||
|
||||
@Test
|
||||
void within_band_order_day_precision_sorts_before_year() {
|
||||
var dayEntry = letter(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "B-brief");
|
||||
var yearEntry = letter(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "A-brief");
|
||||
|
||||
var sorted = List.of(yearEntry, dayEntry).stream()
|
||||
.sorted(TimelineService.WITHIN_BAND_ORDER)
|
||||
.toList();
|
||||
|
||||
assertThat(sorted).containsExactly(dayEntry, yearEntry);
|
||||
}
|
||||
|
||||
@Test
|
||||
void within_band_order_same_precision_and_date_sorts_alphabetically() {
|
||||
var entryZ = letter(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Zimmer");
|
||||
var entryA = letter(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Adler");
|
||||
|
||||
var sorted = List.of(entryZ, entryA).stream()
|
||||
.sorted(TimelineService.WITHIN_BAND_ORDER)
|
||||
.toList();
|
||||
|
||||
assertThat(sorted).containsExactly(entryA, entryZ);
|
||||
}
|
||||
|
||||
@Test
|
||||
void within_band_order_same_title_uses_document_id_as_tiebreak() {
|
||||
UUID id1 = UUID.fromString("00000000-0000-0000-0000-000000000001");
|
||||
UUID id2 = UUID.fromString("00000000-0000-0000-0000-000000000002");
|
||||
var e1 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
|
||||
LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id1, List.of(), null);
|
||||
var e2 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
|
||||
LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id2, List.of(), null);
|
||||
|
||||
var sorted = List.of(e2, e1).stream()
|
||||
.sorted(TimelineService.WITHIN_BAND_ORDER)
|
||||
.toList();
|
||||
|
||||
assertThat(sorted.get(0).documentId()).isEqualTo(id1);
|
||||
}
|
||||
|
||||
// ─── Assembly tests (issue-spec order) ──────────────────────────────────
|
||||
|
||||
@Test
|
||||
void test1_empty_archive_returns_empty_dto() {
|
||||
// REQ-013, REQ-007
|
||||
when(eventRepository.findAll()).thenReturn(List.of());
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of());
|
||||
|
||||
TimelineDTO result = timelineService.assemble(noFilters());
|
||||
|
||||
assertThat(result.years()).isEmpty();
|
||||
assertThat(result.undated()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void test2_one_year_letter_returns_one_year_band() {
|
||||
// REQ-007
|
||||
var doc = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR);
|
||||
when(eventRepository.findAll()).thenReturn(List.of());
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
|
||||
|
||||
TimelineDTO result = timelineService.assemble(noFilters());
|
||||
|
||||
assertThat(result.years()).hasSize(1);
|
||||
assertThat(result.years().get(0).year()).isEqualTo(1914);
|
||||
assertThat(result.years().get(0).entries()).hasSize(1);
|
||||
assertThat(result.years().get(0).entries().get(0).kind()).isEqualTo(Kind.LETTER);
|
||||
assertThat(result.undated()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void test3a_null_date_letter_goes_to_undated() {
|
||||
// REQ-003
|
||||
var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
|
||||
.metaDatePrecision(DatePrecision.YEAR).build(); // documentDate stays null
|
||||
when(eventRepository.findAll()).thenReturn(List.of());
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
|
||||
|
||||
TimelineDTO result = timelineService.assemble(noFilters());
|
||||
|
||||
assertThat(result.years()).isEmpty();
|
||||
assertThat(result.undated()).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void test3b_unknown_precision_letter_goes_to_undated() {
|
||||
// REQ-003
|
||||
var doc = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.UNKNOWN);
|
||||
when(eventRepository.findAll()).thenReturn(List.of());
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
|
||||
|
||||
TimelineDTO result = timelineService.assemble(noFilters());
|
||||
|
||||
assertThat(result.years()).isEmpty();
|
||||
assertThat(result.undated()).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void test4_letter_with_null_sender_and_null_senderText_produces_empty_names() {
|
||||
// REQ-005
|
||||
var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
|
||||
.metaDatePrecision(DatePrecision.YEAR)
|
||||
.documentDate(LocalDate.of(1914, 1, 1))
|
||||
.build(); // no sender, no senderText, no receivers, no receiverText
|
||||
when(eventRepository.findAll()).thenReturn(List.of());
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
|
||||
|
||||
TimelineDTO result = timelineService.assemble(noFilters());
|
||||
|
||||
var entry = result.years().get(0).entries().get(0);
|
||||
assertThat(entry.senderName()).isEqualTo("");
|
||||
assertThat(entry.receiverName()).isEqualTo("");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test5_day_precision_sorts_before_year_in_same_year_band() {
|
||||
// REQ-002
|
||||
var dayLetter = docWithDate(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "B-brief");
|
||||
var yearLetter = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "A-brief");
|
||||
when(eventRepository.findAll()).thenReturn(List.of());
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of(yearLetter, dayLetter));
|
||||
|
||||
TimelineDTO result = timelineService.assemble(noFilters());
|
||||
|
||||
var entries = result.years().get(0).entries();
|
||||
assertThat(entries).hasSize(2);
|
||||
assertThat(entries.get(0).precision()).isEqualTo(DatePrecision.DAY);
|
||||
assertThat(entries.get(1).precision()).isEqualTo(DatePrecision.YEAR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void test6_same_precision_same_date_sorted_alphabetically_by_title() {
|
||||
// REQ-002
|
||||
var letterZ = docWithDate(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Zimmer");
|
||||
var letterA = docWithDate(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Adler");
|
||||
when(eventRepository.findAll()).thenReturn(List.of());
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of(letterZ, letterA));
|
||||
|
||||
TimelineDTO result = timelineService.assemble(noFilters());
|
||||
|
||||
var entries = result.years().get(0).entries();
|
||||
assertThat(entries).hasSize(2);
|
||||
assertThat(entries.get(0).title()).isEqualTo("Adler");
|
||||
assertThat(entries.get(1).title()).isEqualTo("Zimmer");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test7a_range_event_placed_only_in_start_year_band() {
|
||||
// REQ-004
|
||||
var rangeEvent = event("WW1", EventType.HISTORICAL,
|
||||
LocalDate.of(1914, 7, 28), DatePrecision.RANGE, LocalDate.of(1918, 11, 11));
|
||||
when(eventRepository.findAll()).thenReturn(List.of(rangeEvent));
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of());
|
||||
|
||||
TimelineDTO result = timelineService.assemble(noFilters());
|
||||
|
||||
assertThat(result.years()).hasSize(1);
|
||||
assertThat(result.years().get(0).year()).isEqualTo(1914);
|
||||
assertThat(result.years().stream().noneMatch(y -> y.year() == 1918)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void test7b_range_event_with_null_eventDateEnd_does_not_crash() {
|
||||
// REQ-004
|
||||
var rangeEvent = event("Offener Zeitraum", EventType.PERSONAL,
|
||||
LocalDate.of(1914, 1, 1), DatePrecision.RANGE, null);
|
||||
when(eventRepository.findAll()).thenReturn(List.of(rangeEvent));
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of());
|
||||
|
||||
assertThatCode(() -> timelineService.assemble(noFilters())).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void test8_range_event_excluded_when_start_year_before_fromYear() {
|
||||
// REQ-004
|
||||
var rangeEvent = event("WW1", EventType.HISTORICAL,
|
||||
LocalDate.of(1914, 7, 28), DatePrecision.RANGE, LocalDate.of(1918, 11, 11));
|
||||
when(eventRepository.findAll()).thenReturn(List.of(rangeEvent));
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of());
|
||||
|
||||
// fromYear=1915 → start year 1914 is outside → excluded
|
||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1915, null));
|
||||
|
||||
assertThat(result.years()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void test9a_type_filter_does_not_exclude_letters_but_excludes_wrong_type_events() {
|
||||
// REQ-009
|
||||
var doc = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "Brief");
|
||||
var historicalEvent = event("Sarajevo", EventType.HISTORICAL,
|
||||
LocalDate.of(1914, 6, 28), DatePrecision.DAY, null);
|
||||
var personalEvent = event("Geburt", EventType.PERSONAL,
|
||||
LocalDate.of(1914, 8, 1), DatePrecision.DAY, null);
|
||||
when(eventRepository.findAll()).thenReturn(List.of(historicalEvent, personalEvent));
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
|
||||
|
||||
// filter: only HISTORICAL events
|
||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, EventType.HISTORICAL, null, null));
|
||||
|
||||
long letters = result.years().stream().flatMap(y -> y.entries().stream())
|
||||
.filter(e -> e.kind() == Kind.LETTER).count();
|
||||
long personalEvents = result.years().stream().flatMap(y -> y.entries().stream())
|
||||
.filter(e -> e.kind() == Kind.EVENT && e.type() == EventType.PERSONAL).count();
|
||||
assertThat(letters).isEqualTo(1);
|
||||
assertThat(personalEvents).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void test9b_generation_filter_includes_letter_when_sender_matches_generation() {
|
||||
// REQ-010
|
||||
var sender = Person.builder().id(UUID.randomUUID())
|
||||
.lastName("Mustermann").firstName("Max").generation(2).build();
|
||||
var included = Document.builder().id(UUID.randomUUID()).title("Treffer")
|
||||
.metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1))
|
||||
.sender(sender).build();
|
||||
var excluded = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "Kein Treffer"); // no sender
|
||||
when(eventRepository.findAll()).thenReturn(List.of());
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of(included, excluded));
|
||||
when(personService.getPersonsByGeneration(2)).thenReturn(List.of(sender));
|
||||
|
||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, 2, null, null, null));
|
||||
|
||||
assertThat(result.years()).hasSize(1);
|
||||
assertThat(result.years().get(0).entries()).hasSize(1);
|
||||
assertThat(result.years().get(0).entries().get(0).title()).isEqualTo("Treffer");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test9c_fromYear_toYear_inclusive_single_year_window() {
|
||||
// REQ-011
|
||||
var before = docWithDate(LocalDate.of(1913, 12, 31), DatePrecision.YEAR, "Vorher");
|
||||
var inYear = docWithDate(LocalDate.of(1914, 6, 1), DatePrecision.MONTH, "Im Jahr");
|
||||
var after = docWithDate(LocalDate.of(1915, 1, 1), DatePrecision.YEAR, "Nachher");
|
||||
when(eventRepository.findAll()).thenReturn(List.of());
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of(before, inYear, after));
|
||||
|
||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1914, 1914));
|
||||
|
||||
assertThat(result.years()).hasSize(1);
|
||||
assertThat(result.years().get(0).year()).isEqualTo(1914);
|
||||
assertThat(result.years().get(0).entries().get(0).title()).isEqualTo("Im Jahr");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test10_adversarial_and_logic_neither_event_passes_both_filters() {
|
||||
// REQ-012 — type AND year must both pass
|
||||
var wrongType = event("Personal", EventType.PERSONAL,
|
||||
LocalDate.of(1914, 1, 1), DatePrecision.YEAR, null);
|
||||
var wrongYear = event("Historical outside", EventType.HISTORICAL,
|
||||
LocalDate.of(1920, 1, 1), DatePrecision.YEAR, null);
|
||||
when(eventRepository.findAll()).thenReturn(List.of(wrongType, wrongYear));
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of());
|
||||
|
||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, EventType.HISTORICAL, 1914, 1914));
|
||||
|
||||
assertThat(result.years()).isEmpty();
|
||||
assertThat(result.undated()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void test11_personId_scoping_deduplicates_letter_appearing_as_sender_and_receiver() {
|
||||
// REQ-008
|
||||
UUID personId = UUID.randomUUID();
|
||||
var person = Person.builder().id(personId).lastName("Mustermann").build();
|
||||
var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
|
||||
.metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1))
|
||||
.sender(person)
|
||||
.receivers(Set.of(person))
|
||||
.build();
|
||||
when(personService.getById(personId)).thenReturn(person);
|
||||
when(eventRepository.findAll()).thenReturn(List.of());
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getDocumentsBySender(personId)).thenReturn(List.of(doc));
|
||||
when(documentService.getDocumentsByReceiver(personId)).thenReturn(List.of(doc));
|
||||
|
||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(personId, null, null, null, null));
|
||||
|
||||
long total = result.years().stream().mapToLong(y -> y.entries().size()).sum()
|
||||
+ result.undated().size();
|
||||
assertThat(total).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void test12_personId_plus_generation_filter_returns_empty_when_generations_do_not_match() {
|
||||
// REQ-012
|
||||
UUID personId = UUID.randomUUID();
|
||||
var person = Person.builder().id(personId).lastName("Mustermann").generation(1).build();
|
||||
var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
|
||||
.metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1))
|
||||
.sender(person).build();
|
||||
var gen2person = Person.builder().id(UUID.randomUUID()).lastName("Schmidt").generation(2).build();
|
||||
when(personService.getById(personId)).thenReturn(person);
|
||||
when(eventRepository.findAll()).thenReturn(List.of());
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getDocumentsBySender(personId)).thenReturn(List.of(doc));
|
||||
when(documentService.getDocumentsByReceiver(personId)).thenReturn(List.of());
|
||||
when(personService.getPersonsByGeneration(2)).thenReturn(List.of(gen2person)); // person not in gen2
|
||||
|
||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(personId, 2, null, null, null));
|
||||
|
||||
assertThat(result.years()).isEmpty();
|
||||
assertThat(result.undated()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void test13_null_generation_sender_not_returned_by_generation_filter() {
|
||||
// REQ-020 — both sender and receiver have null generation → excluded
|
||||
var nullGenSender = Person.builder().id(UUID.randomUUID()).lastName("Sender").build(); // generation = null
|
||||
var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
|
||||
.metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1))
|
||||
.sender(nullGenSender).build();
|
||||
when(eventRepository.findAll()).thenReturn(List.of());
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
|
||||
when(personService.getPersonsByGeneration(1)).thenReturn(List.of()); // nobody in generation 1
|
||||
|
||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, 1, null, null, null));
|
||||
|
||||
assertThat(result.years()).isEmpty();
|
||||
assertThat(result.undated()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void test14_year_band_contains_only_event_when_no_letters_in_that_year() {
|
||||
var ev = event("Ausbruch", EventType.HISTORICAL, LocalDate.of(1914, 7, 28), DatePrecision.DAY, null);
|
||||
when(eventRepository.findAll()).thenReturn(List.of(ev));
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of());
|
||||
|
||||
TimelineDTO result = timelineService.assemble(noFilters());
|
||||
|
||||
assertThat(result.years()).hasSize(1);
|
||||
assertThat(result.years().get(0).entries()).hasSize(1);
|
||||
assertThat(result.years().get(0).entries().get(0).kind()).isEqualTo(Kind.EVENT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void test15_range_event_start_year_equal_to_fromYear_is_included() {
|
||||
// REQ-004 — inclusive lower bound
|
||||
var rangeEvent = event("WW1", EventType.HISTORICAL,
|
||||
LocalDate.of(1914, 7, 28), DatePrecision.RANGE, LocalDate.of(1918, 11, 11));
|
||||
when(eventRepository.findAll()).thenReturn(List.of(rangeEvent));
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of());
|
||||
|
||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1914, null));
|
||||
|
||||
assertThat(result.years()).hasSize(1);
|
||||
assertThat(result.years().get(0).year()).isEqualTo(1914);
|
||||
}
|
||||
|
||||
@Test
|
||||
void test16_fromYear_without_toYear_returns_all_items_from_that_year_onwards() {
|
||||
// REQ-011
|
||||
var old = docWithDate(LocalDate.of(1919, 12, 31), DatePrecision.YEAR, "Alt");
|
||||
var first = docWithDate(LocalDate.of(1920, 1, 1), DatePrecision.YEAR, "Erst");
|
||||
var newer = docWithDate(LocalDate.of(1921, 6, 1), DatePrecision.YEAR, "Newer");
|
||||
when(eventRepository.findAll()).thenReturn(List.of());
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of(old, first, newer));
|
||||
|
||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1920, null));
|
||||
|
||||
assertThat(result.years()).hasSize(2);
|
||||
assertThat(result.years().stream().noneMatch(y -> y.year() == 1919)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromYear_greater_than_toYear_throws_bad_request() {
|
||||
// REQ-016 (service-layer guard)
|
||||
assertThatThrownBy(() -> timelineService.assemble(new TimelineFilter(null, null, null, 1920, 1914)))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static TimelineFilter noFilters() {
|
||||
return new TimelineFilter(null, null, null, null, null);
|
||||
}
|
||||
|
||||
private static TimelineEntryDTO letter(LocalDate date, DatePrecision precision, String title) {
|
||||
return new TimelineEntryDTO(Kind.LETTER, precision, false, "", "",
|
||||
date, null, title, null, null, UUID.randomUUID(), List.of(), null);
|
||||
}
|
||||
|
||||
private static Document docWithDate(LocalDate date, DatePrecision precision) {
|
||||
return Document.builder().id(UUID.randomUUID()).title("Brief")
|
||||
.metaDatePrecision(precision).documentDate(date).build();
|
||||
}
|
||||
|
||||
private static Document docWithDate(LocalDate date, DatePrecision precision, String title) {
|
||||
return Document.builder().id(UUID.randomUUID()).title(title)
|
||||
.metaDatePrecision(precision).documentDate(date).build();
|
||||
}
|
||||
|
||||
private static TimelineEvent event(String title, EventType type, LocalDate date,
|
||||
DatePrecision precision, LocalDate endDate) {
|
||||
return TimelineEvent.builder().id(UUID.randomUUID())
|
||||
.title(title).type(type)
|
||||
.eventDate(date).precision(precision).eventDateEnd(endDate)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -168,18 +168,7 @@ _Not to be confused with a document item's optional note_ — a document item's
|
||||
|
||||
**EventType** (`EventType`) `[user-facing]` — the kind of a `TimelineEvent`: `PERSONAL` (a family event, rendered with the family accent) or `HISTORICAL` (world/historical context, rendered with a muted world accent). The string value names are a stable frontend styling contract — renaming requires a coordinated frontend change (ADR-040).
|
||||
|
||||
**Zeitstrahl** `[user-facing]` — the family timeline view, rendering curated `TimelineEvent`s and derived life-events chronologically. The milestone home of the `timeline` domain.
|
||||
|
||||
**Lebensweg** `[user-facing]` — the per-person variant of the *Zeitstrahl*: the same `TimelineView` component, scoped to a single person via a `personId` prop, rendering that person's life-events, events, and letters as a left-anchored rail. The global Zeitstrahl is the `personId`-undefined case of the same component (issue #10 wires the per-person rail; the prop seam ships with the global view).
|
||||
|
||||
**derived event** — a timeline entry computed on-read from curated `Person` or `PersonRelationship` data, never persisted. Carried as a `TimelineEntryDTO` with `derived=true` and a non-null `DerivedEventType`. Three subtypes: Geburt (birth, from `Person.birthDate`), Tod (death, from `Person.deathDate`), Heirat (marriage, from a `SPOUSE_OF` `PersonRelationship` edge). Callers of `assembleDerivedEvents()` are responsible for enforcing `READ_ALL` authorization before invoking it (ADR-043).
|
||||
_Not to be confused with a `TimelineEvent`_ — a `TimelineEvent` is a curated record authored by a human and stored in `timeline_events`; a derived event is computed on-the-fly and never written to the database.
|
||||
|
||||
**DerivedEventType** (`DerivedEventType`) `[internal]` — enum with three values: `BIRTH`, `DEATH`, `MARRIAGE`. Carried on `TimelineEntryDTO.derivedType`; `null` on curated-event entries exposed through the same DTO.
|
||||
|
||||
**derivedType** (`TimelineEntryDTO.derivedType`) `[internal]` — the `DerivedEventType` field distinguishing a derived Geburt/Tod/Heirat event from a curated one. Always non-null on derived events; `null` on curated events.
|
||||
|
||||
**assembleDerivedEvents()** (`TimelineEventService.assembleDerivedEvents()`) `[internal]` — the public `@Transactional(readOnly=true)` method that computes all derived events in one call: one batch fetch of family-member `Person`s via `PersonService.findAllFamilyMembers()` and one batch fetch of `SPOUSE_OF` edges via `RelationshipService.findAllSpouseEdges()`. Result is never persisted. Synthetic ids produced by this method (`birth:{uuid}`, `death:{uuid}`, `marriage:{uuid}`) are structurally non-UUID and must be rejected by any write endpoint. See ADR-043.
|
||||
**Zeitstrahl** `[user-facing]` — the family timeline view, rendering curated `TimelineEvent`s (and, in later issues, derived life-events) chronologically. The milestone home of the `timeline` domain.
|
||||
|
||||
**Notification** (`Notification`) — an in-app message delivered to an `AppUser`. No email or SMS delivery exists today. Delivered via Server-Sent Events (`SseEmitterRegistry`) and persisted in the `notifications` table.
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
# ADR-042 — Adopt Spec-Driven Development (SDD)
|
||||
# ADR-041 — Adopt Spec-Driven Development (SDD)
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-06-13
|
||||
**Issue:** SDD integration (docs/sdd-integration branch)
|
||||
|
||||
> This is the "ADR-000" the SDD scaffold refers to, numbered 042 to fit the existing archive
|
||||
> sequence (041 was taken by the Renovate runner-setup ADR merged in parallel). See
|
||||
> [`.specify/adrs/README.md`](../../.specify/adrs/README.md).
|
||||
> This is the "ADR-000" the SDD scaffold refers to, numbered 041 to fit the existing archive
|
||||
> sequence rather than starting a parallel one. See [`.specify/adrs/README.md`](../../.specify/adrs/README.md).
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
# ADR-043 — Derived person life-events: on-read assembly strategy
|
||||
|
||||
**Status:** Proposed
|
||||
**Date:** 2026-06-13
|
||||
**Issue:** #776 — Timeline: derive person life-events (Geburt/Tod/Heirat)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The Zeitstrahl (family timeline) must surface births, deaths, and marriages alongside
|
||||
manually curated `TimelineEvent` rows. This data already exists in the `Person` entity
|
||||
(`birthDate`, `deathDate`, `birthDatePrecision`, `deathDatePrecision`) and in
|
||||
`PersonRelationship` rows with `relationType = SPOUSE_OF`.
|
||||
|
||||
Three architectural decisions needed before implementation could start:
|
||||
|
||||
1. **Computation strategy:** should derived events be materialised to the `timeline_events`
|
||||
table, or assembled on every read from the source tables?
|
||||
2. **Id format:** how do we give derived events stable, unambiguous ids that cannot collide
|
||||
with real `TimelineEvent` UUIDs and signal read-only semantics to consumers?
|
||||
3. **Service contract:** where does the assembly method live, and what is its public API?
|
||||
|
||||
---
|
||||
|
||||
## Decision 1 — On-read assembly, never persisted
|
||||
|
||||
Derived events are computed on every call to `assembleDerivedEvents()` and are never written
|
||||
to any table.
|
||||
|
||||
**Alternatives rejected:**
|
||||
|
||||
| Alternative | Reason rejected |
|
||||
|-------------|-----------------|
|
||||
| Materialise to `timeline_events` | Requires a synchronisation job or domain-event wiring every time a `Person` or `PersonRelationship` is mutated. Adds complexity, drift risk, and a write path for data that is fundamentally derived. |
|
||||
| Separate `derived_events` table | Same sync problem; adds schema migration for data that is a pure projection. |
|
||||
| Cache in-process | Adds invalidation complexity for MVP scale (tens to low hundreds of persons). Can be added later if `findAllFamilyMembers()` exceeds ~500 rows. |
|
||||
|
||||
**Consequences:**
|
||||
- No schema changes. No Flyway migration.
|
||||
- The method must be `@Transactional(readOnly = true)` to keep the Hibernate session open
|
||||
across the lazy-association reads that `buildMarriageEvents()` performs via JOIN FETCH.
|
||||
- Every caller of `assembleDerivedEvents()` triggers two DB queries: one for family-member
|
||||
persons, one for spouse edges with JOIN FETCH. Acceptable at MVP scale.
|
||||
|
||||
---
|
||||
|
||||
## Decision 2 — Synthetic prefixed String ids
|
||||
|
||||
Derived events receive ids of the form `birth:{personId}`, `death:{personId}`,
|
||||
`marriage:{relationshipId}`, where the suffix is the UUID of the source entity.
|
||||
|
||||
**Format rules:**
|
||||
- `id` field on `TimelineEntryDTO` is typed `String`, NOT `UUID`.
|
||||
- `UUID.fromString(derivedEvent.id())` always throws `IllegalArgumentException` — id is
|
||||
structurally non-UUID by construction.
|
||||
- The `unique_spouse_pair` DB index (V55) is the authoritative dedup guard for marriages;
|
||||
the in-memory `Set<UUID>` used during assembly is a defensive assertion, not primary
|
||||
enforcement.
|
||||
|
||||
**Alternatives rejected:**
|
||||
|
||||
| Alternative | Reason rejected |
|
||||
|-------------|-----------------|
|
||||
| Random UUID for each call | Not stable across calls — consumers (frontend, #5 sort/bucket) could not use ids as stable keys. |
|
||||
| UUID typed field with a sentinel namespace (RFC 4122 v5) | Requires hashing; still looks like a UUID and could be confused with real event ids by write endpoints. |
|
||||
| Numeric sequence | No natural source sequence; would require a counter, adding state. |
|
||||
|
||||
**Consequences:**
|
||||
- `TimelineEntryDTO.id` must be `String`. The existing `TimelineEventView.id` is `UUID` and
|
||||
serves a different purpose (CRUD admin view); it is not changed.
|
||||
- Any write endpoint that accepts a timeline event id (`PUT`, `DELETE`) must reject ids that
|
||||
do not parse as `UUID` — enforced and tested in issue #5, not here.
|
||||
- Ids are deterministic and stable for the lifetime of the source entity, enabling client-side
|
||||
caching and deduplication.
|
||||
|
||||
---
|
||||
|
||||
## Decision 3 — `assembleDerivedEvents()` as the public cross-issue contract
|
||||
|
||||
The assembly method lives on `TimelineService` as a `public` method. Issue #5 (the
|
||||
`GET /api/timeline` endpoint) calls it directly on the injected `TimelineService` bean.
|
||||
|
||||
**Domain boundary rules enforced by this decision:**
|
||||
- `TimelineService` reaches `Person` and `PersonRelationship` data **only through
|
||||
`PersonService.findAllFamilyMembers()` and `RelationshipService.findAllSpouseEdges()`**.
|
||||
It never injects `PersonRepository` or `PersonRelationshipRepository`.
|
||||
- The three private builder methods (`buildBirthEvents`, `buildDeathEvents`,
|
||||
`buildMarriageEvents`) are implementation details; only `assembleDerivedEvents()` is public.
|
||||
- **Authorization:** `assembleDerivedEvents()` performs no authorization check. The calling
|
||||
endpoint in #5 must enforce `READ_ALL` before invoking this method. Any future caller
|
||||
outside #5 must do the same — this obligation is documented in the Javadoc of the method.
|
||||
|
||||
**Alternatives rejected:**
|
||||
|
||||
| Alternative | Reason rejected |
|
||||
|-------------|-----------------|
|
||||
| Separate `DerivedEventService` | Adds a class for a cohesive set of methods that belong to the timeline domain. Timeline owns the DTO shape; splitting it out is premature. |
|
||||
| Expose via `PersonService` | Person domain should not know about `TimelineEntryDTO`. Cross-cutting concern belongs in timeline. |
|
||||
|
||||
---
|
||||
|
||||
## Related decisions
|
||||
|
||||
- ADR-039 — Person life-dates stored as `LocalDate` + `DatePrecision` (the source data this
|
||||
issue reads)
|
||||
- ADR-040 — Timeline domain data model (establishes the `timeline/` package and
|
||||
`TimelineEvent` entity this issue extends)
|
||||
- ADR-036 — Responses as views, never raw entities (why `assembleDerivedEvents()` returns
|
||||
`List<TimelineEntryDTO>`, not raw `Person` or `PersonRelationship` entities)
|
||||
@@ -6,28 +6,19 @@ title Component Diagram: API Backend — Timeline (Zeitstrahl)
|
||||
ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(timelineRepo, "TimelineEventRepository", "Spring Data JPA", "Reads and writes TimelineEvent rows and their persons/documents join tables (timeline_event_persons, timeline_event_documents).")
|
||||
Component(timelineRepo, "TimelineEventRepository", "Spring Data JPA", "Reads and writes TimelineEvent rows and their persons/documents join tables (timeline_event_persons, timeline_event_documents). Issue #774 ships the repository empty; the per-person filter query lands in #777.")
|
||||
|
||||
Component(timelineSvc, "TimelineEventService", "Spring Service", "Owns curated-event CRUD: assembles TimelineEventView inside the transaction (lazy ManyToMany + open-in-view=false, ADR-036/ADR-040), populates createdBy/updatedBy from the session principal, and translates optimistic-lock conflicts to DomainException.conflict. Also exposes assembleDerivedEvents(): computes Geburt/Tod/Heirat TimelineEntryDTOs on read from Person/PersonRelationship data — never persisted (ADR-043).")
|
||||
Component(timelineCtrl, "TimelineEventController", "Spring MVC", "Exposes /api/timeline/events reads (READ_ALL) and writes (WRITE_ALL). createdBy/updatedBy are never bound from request bodies (CWE-639).")
|
||||
|
||||
Component(timelineAssemblySvc, "TimelineService", "Spring Service", "Assembles GET /api/timeline response: merges curated TimelineEvent rows, derived life-events (via TimelineEventService), and archive letters (via DocumentService) into a year-bucketed TimelineDTO. Applies personId, generation, type, fromYear/toYear filters. WITHIN_BAND_ORDER: precision rank desc → date asc → title alpha → id tiebreak.")
|
||||
Component(timelineAssemblyCtrl, "TimelineController", "Spring MVC", "Exposes GET /api/timeline (READ_ALL). Five optional query params: personId, generation (@Min(0)), type (EventType enum), fromYear, toYear. @Validated on class for constraint enforcement.")
|
||||
Component(timelineSvc, "TimelineEventService", "Spring Service (planned, #775)", "Will own curated-event CRUD: assemble TimelineEventView/Summary inside the transaction (lazy ManyToMany + open-in-view=false, per ADR-036/ADR-040), populate createdBy/updatedBy from the session principal, and translate optimistic-lock conflicts to DomainException.conflict.")
|
||||
Component(timelineCtrl, "TimelineEventController", "Spring MVC (planned, #775)", "Will expose /api/timeline reads (READ_ALL) and writes (WRITE_ALL). createdBy/updatedBy are never bound from request bodies (CWE-639).")
|
||||
}
|
||||
|
||||
System_Ext(documentDomain, "Document domain", "Provides DatePrecision (shared value type), Document references for linked letters, and getAllForTimeline() bulk fetch")
|
||||
System_Ext(personDomain, "Person domain", "Provides Person references (PersonService.findAllFamilyMembers, getPersonsByGeneration, getById) and SPOUSE_OF edges (RelationshipService.findAllSpouseEdges) for derived-event assembly and generation filtering")
|
||||
System_Ext(documentDomain, "Document domain", "Provides DatePrecision (shared value type) and Document references for linked letters")
|
||||
System_Ext(personDomain, "Person domain", "Provides Person references for who an event involves")
|
||||
|
||||
Rel(timelineRepo, db, "SQL queries", "JDBC")
|
||||
Rel(timelineSvc, timelineRepo, "Reads / writes events")
|
||||
Rel(timelineCtrl, timelineSvc, "Delegates to")
|
||||
Rel(timelineSvc, timelineRepo, "Reads / writes events (planned)")
|
||||
Rel(timelineCtrl, timelineSvc, "Delegates to (planned)")
|
||||
Rel(timelineRepo, personDomain, "References persons via join table")
|
||||
Rel(timelineRepo, documentDomain, "References documents via join table")
|
||||
Rel(timelineSvc, personDomain, "findAllFamilyMembers() + findAllSpouseEdges() for derived-event assembly")
|
||||
Rel(timelineAssemblyCtrl, timelineAssemblySvc, "Delegates to")
|
||||
Rel(timelineAssemblySvc, timelineRepo, "findAll() for curated events")
|
||||
Rel(timelineAssemblySvc, timelineSvc, "assembleDerivedEvents() for derived life-events")
|
||||
Rel(timelineAssemblySvc, personDomain, "getPersonsByGeneration(), getById() for generation/personId filters")
|
||||
Rel(timelineAssemblySvc, documentDomain, "getAllForTimeline(), getDocumentsBySender(), getDocumentsByReceiver() for letter layer")
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -14,8 +14,6 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
||||
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story/Journey list and detail pages. List: GeschichteListRow with REISE badge for JOURNEY type. Detail: dispatches to StoryReader (rich text + persons) or JourneyReader (intro + ordered JourneyItemCard/JourneyInterlude items + empty state) based on GeschichteType. BLOG_WRITE users see edit/delete actions. Loader: GET /api/geschichten, GET /api/geschichten/{id}.")
|
||||
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor and creation flow. New: TypeSelector (STORY/JOURNEY radio group with roving tabindex) → StoryCreate (TipTap rich text, person linking, POST /api/geschichten) or JourneyCreate (title + first item). Edit: branches on GeschichteType — STORY opens GeschichteEditor (TipTap body + GeschichteSidebar incl. StoryDocumentPanel: document linking via POST/DELETE /items); JOURNEY opens JourneyEditor (title, intro textarea, ordered JourneyItemRow list with drag-reorder + move-up/down, JourneyAddBar for document/interlude addition, GeschichteSidebar). JourneyEditor mutations: POST/DELETE /items, PUT /items/reorder, PATCH /items/{id}. Requires BLOG_WRITE permission.")
|
||||
Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.")
|
||||
Component(zeitstrahl, "/zeitstrahl", "SvelteKit Route", "Global timeline (Zeitstrahl). SSR loader: GET /api/timeline -> TimelineDTO. Renders lib/timeline/TimelineView (Datum mode): year bands (YearBand) with EventPill / WorldBand / LetterCard, dense-year YearLetterStrip (shared Sparkline + monthHistogram), folded GapSpan for empty-year runs, and an undated bucket. personId prop is the per-person Lebensweg seam (issue #10), undefined here.")
|
||||
Component(zeitstrahlEvents, "/zeitstrahl/events/new and /zeitstrahl/events/[id]/edit", "SvelteKit Routes", "Curator event editor (WRITE_ALL-gated via server load, 403 error page). One lib/timeline/EventForm for both routes: title, EventTypeSelect (PERSONAL/HISTORICAL segmented radio), shared DatePrecisionField (RANGE reveals end date), plain-text description, PersonMultiSelect + DocumentMultiSelect. New: ?personId/?documentId prefill via Promise.all (404/403 swallowed), POST /api/timeline/events. Edit: load seeds from GET /api/timeline/events/{id} (404 on any non-ok — fails closed against derived events), PUT (optimistic-lock version) + DELETE behind ConfirmDialog. Context-aware redirect via UUID-validated originPersonId.")
|
||||
Component(themen, "/themen", "SvelteKit Route", "Browsable topic index. Shows all root tags as cards with color bars and child rows. ThemenWidget also embedded in the home dashboard (reader + editor sidebar). Loader: GET /api/tags/tree.")
|
||||
Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.")
|
||||
Component(userProfile, "/users/[id]", "SvelteKit Route", "Public user profile view. Loader: GET /api/users/{id}.")
|
||||
@@ -29,9 +27,6 @@ Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications"
|
||||
Rel(geschichten, backend, "GET /api/geschichten, GET /api/geschichten/{id}, DELETE /api/geschichten/{id}", "HTTP / JSON")
|
||||
Rel(geschichtenEdit, backend, "GET /api/persons/{id} (pre-populate), POST /api/geschichten, PUT /api/geschichten/{id}, POST/DELETE /api/geschichten/{id}/items, PUT /api/geschichten/{id}/items/reorder, PATCH /api/geschichten/{id}/items/{itemId}", "HTTP / JSON")
|
||||
Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON")
|
||||
Rel(user, zeitstrahl, "Reads the family timeline", "HTTPS / Browser")
|
||||
Rel(zeitstrahl, backend, "GET /api/timeline -> TimelineDTO", "HTTP / JSON")
|
||||
Rel(zeitstrahlEvents, backend, "GET /api/timeline/events/{id}, POST /api/timeline/events, PUT/DELETE /api/timeline/events/{id}, GET /api/persons/{id} + /api/documents/{id} (prefill)", "HTTP / JSON")
|
||||
Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON")
|
||||
Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")
|
||||
Rel(userProfile, backend, "GET /api/users/{id}", "HTTP / JSON")
|
||||
|
||||
@@ -34,7 +34,6 @@ src/
|
||||
│ ├── api/ # Internal API proxies (server-side only)
|
||||
│ ├── geschichten/ # Stories (list, [id], [id]/edit, new)
|
||||
│ ├── stammbaum/ # Family tree
|
||||
│ ├── zeitstrahl/ # Global timeline (Zeitstrahl) — SSR loads /api/timeline, renders lib/timeline; events/new + events/[id]/edit curator editor (WRITE_ALL-gated)
|
||||
│ ├── enrich/ # Enrichment workflow ([id], done)
|
||||
│ ├── hilfe/transkription/ # Transcription help page
|
||||
│ ├── profile/ # User profile settings
|
||||
@@ -50,7 +49,6 @@ src/
|
||||
│ │ ├── relationship/ # Relationship form + chip components
|
||||
│ │ └── genealogy/ # Stammbaum (family tree) components
|
||||
│ ├── tag/ # Tag domain: TagInput, TagChipList, TagParentPicker
|
||||
│ ├── timeline/ # Timeline (Zeitstrahl) domain: TimelineView, YearBand, EventPill, WorldBand, LetterCard, YearLetterStrip, GapSpan; dateLabel + timelineDensity + eventCardConfig (imports $lib/shared only, never document/)
|
||||
│ ├── geschichte/ # Geschichte (story) domain: editor + card
|
||||
│ ├── notification/ # Notification bell + dropdown + store
|
||||
│ ├── activity/ # Activity feed (Chronik) components
|
||||
@@ -61,8 +59,8 @@ src/
|
||||
│ │ ├── hooks/ # Reusable Svelte state hooks (useTypeahead, etc.)
|
||||
│ │ ├── server/ # Server-only utilities (locale, session)
|
||||
│ │ ├── services/ # Client-side service helpers
|
||||
│ │ ├── utils/ # Pure utility functions (date, search, monthBuckets — month-bucket math shared by document chart + timeline strip)
|
||||
│ │ ├── primitives/ # Generic UI primitives (BackButton, ProgressRing, Sparkline, etc.)
|
||||
│ │ ├── utils/ # Pure utility functions (date, search, etc.)
|
||||
│ │ ├── primitives/ # Generic UI primitives (BackButton, ProgressRing, etc.)
|
||||
│ │ ├── dashboard/ # Dashboard stat components
|
||||
│ │ ├── discussion/ # CommentThread + shared discussion UI
|
||||
│ │ ├── help/ # Help/FAQ page components
|
||||
|
||||
@@ -26,7 +26,7 @@ test.describe('Document auto-title sync (#726)', () => {
|
||||
|
||||
// 3. Add a YEAR-precision date WITHOUT touching the title, then save.
|
||||
await page.locator('#documentDate').fill('15.01.1928');
|
||||
await page.locator('#documentDatePrecision').selectOption('YEAR');
|
||||
await page.locator('#metaDatePrecision').selectOption('YEAR');
|
||||
await page.getByRole('button', { name: 'Speichern', exact: true }).click();
|
||||
|
||||
// 4. The detail page shows the regenerated title carrying the new year.
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Curator timeline event editor (#781) — intentionally thin. The component +
|
||||
* server specs carry the real regression coverage (they run in CI's "Unit &
|
||||
* Component Tests" job); ci.yml does NOT invoke test:e2e today, so this file
|
||||
* runs only locally/manually against the full Docker Compose stack.
|
||||
*
|
||||
* Three checks: one critical create journey (→ HTTP 200 on /zeitstrahl; the full
|
||||
* "sees the event card" assertion depends on #7), one security counterpart
|
||||
* (logged-out → 403), and one 320px no-overflow guarantee for the 60+ author
|
||||
* audience.
|
||||
*/
|
||||
|
||||
const stamp = () => new Date().toISOString().replace(/[^0-9]/g, '');
|
||||
|
||||
test.describe('Curator creates a timeline event', () => {
|
||||
test('fills the create form with precision RANGE and lands on /zeitstrahl (HTTP 200)', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/zeitstrahl/events/new');
|
||||
|
||||
await page.getByLabel(/Titel/i).fill(`E2E Ereignis ${stamp()}`);
|
||||
await page.getByRole('radio', { name: /Historisch/i }).click();
|
||||
|
||||
// Date + RANGE end date via the shared German dd.mm.yyyy inputs.
|
||||
await page.locator('#eventDate').fill('01.04.1925');
|
||||
await page.locator('#eventDatePrecision').selectOption('RANGE');
|
||||
await expect(page.getByLabel('Enddatum')).toBeVisible();
|
||||
await page.locator('#eventDateEnd').fill('01.05.1925');
|
||||
|
||||
// Submitting redirects to the resolved nav target (/zeitstrahl) — assert the
|
||||
// route responds 200, not a DOM card (card rendering is #7's concern).
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/zeitstrahl$/),
|
||||
page.getByRole('button', { name: 'Speichern' }).click()
|
||||
]);
|
||||
const response = await page.goto('/zeitstrahl');
|
||||
expect(response?.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Logged-out user is blocked from the curator route', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('navigating to /zeitstrahl/events/new is blocked with 403', async ({ page }) => {
|
||||
await page.goto('/zeitstrahl/events/new');
|
||||
// The load guard throws 403 before any form renders.
|
||||
await expect(page.getByLabel(/Titel/i)).not.toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByText(/403|Zugriff verweigert|Forbidden/i)).toBeVisible({
|
||||
timeout: 5000
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Responsive — 60+ author audience', () => {
|
||||
test('no horizontal overflow on the create form at 320px', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 320, height: 900 });
|
||||
await page.goto('/zeitstrahl/events/new');
|
||||
await expect(page.getByLabel(/Titel/i)).toBeVisible();
|
||||
|
||||
const scrollWidth = await page.evaluate(() => document.body.scrollWidth);
|
||||
expect(scrollWidth).toBe(320);
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Global /zeitstrahl timeline (#779). Runs against the real stack with the
|
||||
* seeded admin session (auth.setup). Covers the primary journey (nav → page,
|
||||
* timeline inside <main>) and the 320px no-overflow guarantee on a populated
|
||||
* timeline seeded with 25+char correspondent names (REQ-005).
|
||||
*/
|
||||
|
||||
const stamp = () => new Date().toISOString().replace(/[^0-9]/g, '');
|
||||
|
||||
async function createPerson(request: APIRequestContext, firstName: string, lastName: string) {
|
||||
const res = await request.post('/api/persons', {
|
||||
data: { personType: 'PERSON', firstName, lastName }
|
||||
});
|
||||
if (!res.ok()) throw new Error(`create person failed: ${res.status()}`);
|
||||
return (await res.json()).id as string;
|
||||
}
|
||||
|
||||
/** Seeds one dated letter with long sender/receiver names so it lands on the timeline. */
|
||||
async function seedDatedLetter(request: APIRequestContext) {
|
||||
const senderId = await createPerson(
|
||||
request,
|
||||
'Friedrich-Wilhelm',
|
||||
`Maximilian von Habsburg ${stamp()}`
|
||||
);
|
||||
const receiverId = await createPerson(
|
||||
request,
|
||||
'Maria-Magdalena',
|
||||
`Hohenzollern-Sigmaringen ${stamp()}`
|
||||
);
|
||||
|
||||
const createRes = await request.post('/api/documents', {
|
||||
multipart: { title: `E2E Zeitstrahl Brief ${stamp()}` }
|
||||
});
|
||||
if (!createRes.ok()) throw new Error(`create document failed: ${createRes.status()}`);
|
||||
const docId = (await createRes.json()).id as string;
|
||||
|
||||
const put = await request.put(`/api/documents/${docId}`, {
|
||||
multipart: {
|
||||
title: `E2E Zeitstrahl Brief ${stamp()}`,
|
||||
documentDate: '1915-06-15',
|
||||
metaDatePrecision: 'DAY',
|
||||
senderId,
|
||||
receiverIds: receiverId
|
||||
}
|
||||
});
|
||||
if (!put.ok()) throw new Error(`update document failed: ${put.status()}`);
|
||||
}
|
||||
|
||||
test.describe('Zeitstrahl — global timeline (#779)', () => {
|
||||
test('nav link opens /zeitstrahl and the timeline lives in <main>', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('navigation').getByRole('link', { name: 'Zeitstrahl' }).first().click();
|
||||
await expect(page).toHaveURL(/\/zeitstrahl$/);
|
||||
await expect(page.getByRole('heading', { level: 1, name: 'Zeitstrahl' })).toBeVisible();
|
||||
|
||||
// The main landmark contains either the populated <ol> or the empty state.
|
||||
const main = page.getByRole('main');
|
||||
const ol = main.locator('ol');
|
||||
const empty = main.getByText('Noch keine Ereignisse.');
|
||||
await expect(async () => {
|
||||
const populated = (await ol.count()) > 0;
|
||||
const isEmpty = await empty.isVisible().catch(() => false);
|
||||
expect(populated || isEmpty).toBe(true);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('no horizontal overflow at 320px with long correspondent names (REQ-005)', async ({
|
||||
page,
|
||||
request
|
||||
}) => {
|
||||
await seedDatedLetter(request);
|
||||
|
||||
await page.setViewportSize({ width: 320, height: 900 });
|
||||
await page.goto('/zeitstrahl');
|
||||
|
||||
// Populated: the seeded letter puts the timeline <ol> in the DOM.
|
||||
await expect(page.getByRole('main').locator('ol')).toHaveCount(1);
|
||||
|
||||
const scrollWidth = await page.evaluate(() => document.body.scrollWidth);
|
||||
expect(scrollWidth).toBe(320);
|
||||
});
|
||||
});
|
||||
@@ -199,12 +199,7 @@ export default defineConfig(
|
||||
{ from: { type: 'user' }, allow: { to: { type: ['shared'] } } },
|
||||
{ from: { type: 'notification' }, allow: { to: { type: ['shared'] } } },
|
||||
{ from: { type: 'conversation' }, allow: { to: { type: ['shared'] } } },
|
||||
// Timeline curator event editor selects persons and documents by
|
||||
// design (mirrors the geschichte editor) — #781.
|
||||
{
|
||||
from: { type: 'timeline' },
|
||||
allow: { to: { type: ['shared', 'person', 'document'] } }
|
||||
},
|
||||
{ from: { type: 'timeline' }, allow: { to: { type: ['shared'] } } },
|
||||
{ from: { type: 'shared' }, allow: { to: { type: ['shared'] } } },
|
||||
{
|
||||
from: { type: 'routes' },
|
||||
@@ -220,7 +215,6 @@ export default defineConfig(
|
||||
'ocr',
|
||||
'activity',
|
||||
'conversation',
|
||||
'timeline',
|
||||
'shared'
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1032,45 +1032,6 @@
|
||||
"bulk_edit_count_pill": "{count} werden bearbeitet",
|
||||
"nav_stammbaum": "Stammbaum",
|
||||
"nav_geschichten": "Geschichten",
|
||||
"nav_zeitstrahl": "Zeitstrahl",
|
||||
"timeline_heading": "Zeitstrahl",
|
||||
"timeline_empty_state": "Noch keine Ereignisse.",
|
||||
"timeline_undated_section": "Ohne Datum",
|
||||
"timeline_unknown_person": "Unbekannt",
|
||||
"timeline_gap_empty": "keine Einträge",
|
||||
"timeline_letters_count": "{count} Briefe",
|
||||
"timeline_strip_expand": "Briefe anzeigen",
|
||||
"timeline_range_aria": "Zeitraum: {from} bis {to}",
|
||||
"timeline_layer_world": "Weltgeschehen",
|
||||
"timeline_layer_family": "Familie",
|
||||
"timeline_derived_birth": "Geburt",
|
||||
"timeline_derived_death": "Tod",
|
||||
"timeline_derived_marriage": "Heirat",
|
||||
"event_editor_new_title": "Neues Ereignis",
|
||||
"event_editor_edit_title": "Ereignis bearbeiten",
|
||||
"event_editor_section_when": "Wann",
|
||||
"event_editor_section_persons": "Beteiligte Personen",
|
||||
"event_editor_section_documents": "Verknüpfte Briefe",
|
||||
"event_editor_section_description": "Beschreibung",
|
||||
"event_editor_title_label": "Titel",
|
||||
"event_editor_title_placeholder": "Titel des Ereignisses",
|
||||
"event_editor_title_required": "Bitte einen Titel eingeben.",
|
||||
"event_editor_date_required": "Bitte ein Datum eingeben.",
|
||||
"event_editor_type_label": "Typ",
|
||||
"event_editor_persons_label": "Personen",
|
||||
"event_editor_documents_label": "Briefe",
|
||||
"event_editor_description_label": "Beschreibung",
|
||||
"event_editor_description_placeholder": "Optionale Beschreibung",
|
||||
"event_editor_persons_empty": "Noch keine Person verknüpft",
|
||||
"event_editor_documents_empty": "Noch kein Dokument verknüpft",
|
||||
"event_type_PERSONAL": "Persönlich",
|
||||
"event_type_HISTORICAL": "Historisch",
|
||||
"event_editor_save": "Speichern",
|
||||
"event_editor_save_hint": "Ereignisse erscheinen im Zeitstrahl.",
|
||||
"event_editor_delete": "Löschen",
|
||||
"event_editor_delete_confirm_title": "Ereignis löschen?",
|
||||
"event_editor_delete_confirm_body": "Dieses Ereignis wird dauerhaft entfernt.",
|
||||
"event_editor_unsaved_changes": "Du hast ungespeicherte Änderungen — wirklich verlassen?",
|
||||
"error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.",
|
||||
"error_journey_item_not_found": "Der Reise-Eintrag wurde nicht gefunden.",
|
||||
"error_journey_item_position_conflict": "Die Reihenfolge wurde gerade von jemand anderem geändert – bitte laden Sie die Seite neu.",
|
||||
|
||||
@@ -1032,45 +1032,6 @@
|
||||
"bulk_edit_count_pill": "{count} will be edited",
|
||||
"nav_stammbaum": "Family tree",
|
||||
"nav_geschichten": "Stories",
|
||||
"nav_zeitstrahl": "Timeline",
|
||||
"timeline_heading": "Timeline",
|
||||
"timeline_empty_state": "No events yet.",
|
||||
"timeline_undated_section": "Without Date",
|
||||
"timeline_unknown_person": "Unknown",
|
||||
"timeline_gap_empty": "no entries",
|
||||
"timeline_letters_count": "{count} letters",
|
||||
"timeline_strip_expand": "Show letters",
|
||||
"timeline_range_aria": "Period: {from} to {to}",
|
||||
"timeline_layer_world": "World events",
|
||||
"timeline_layer_family": "Family",
|
||||
"timeline_derived_birth": "Birth",
|
||||
"timeline_derived_death": "Death",
|
||||
"timeline_derived_marriage": "Marriage",
|
||||
"event_editor_new_title": "New event",
|
||||
"event_editor_edit_title": "Edit event",
|
||||
"event_editor_section_when": "When",
|
||||
"event_editor_section_persons": "People involved",
|
||||
"event_editor_section_documents": "Linked letters",
|
||||
"event_editor_section_description": "Description",
|
||||
"event_editor_title_label": "Title",
|
||||
"event_editor_title_placeholder": "Event title",
|
||||
"event_editor_title_required": "Please enter a title.",
|
||||
"event_editor_date_required": "Please enter a date.",
|
||||
"event_editor_type_label": "Type",
|
||||
"event_editor_persons_label": "People",
|
||||
"event_editor_documents_label": "Letters",
|
||||
"event_editor_description_label": "Description",
|
||||
"event_editor_description_placeholder": "Optional description",
|
||||
"event_editor_persons_empty": "No person linked yet",
|
||||
"event_editor_documents_empty": "No document linked yet",
|
||||
"event_type_PERSONAL": "Personal",
|
||||
"event_type_HISTORICAL": "Historical",
|
||||
"event_editor_save": "Save",
|
||||
"event_editor_save_hint": "Events appear on the timeline.",
|
||||
"event_editor_delete": "Delete",
|
||||
"event_editor_delete_confirm_title": "Delete event?",
|
||||
"event_editor_delete_confirm_body": "This event will be permanently removed.",
|
||||
"event_editor_unsaved_changes": "You have unsaved changes — really leave?",
|
||||
"error_geschichte_not_found": "The story was not found.",
|
||||
"error_journey_item_not_found": "The journey item was not found.",
|
||||
"error_journey_item_position_conflict": "The order was just changed by someone else — please reload the page.",
|
||||
|
||||
@@ -1032,45 +1032,6 @@
|
||||
"bulk_edit_count_pill": "Se editarán {count}",
|
||||
"nav_stammbaum": "Árbol genealógico",
|
||||
"nav_geschichten": "Historias",
|
||||
"nav_zeitstrahl": "Línea de tiempo",
|
||||
"timeline_heading": "Línea de tiempo",
|
||||
"timeline_empty_state": "Aún no hay eventos.",
|
||||
"timeline_undated_section": "Sin Fecha",
|
||||
"timeline_unknown_person": "Desconocido",
|
||||
"timeline_gap_empty": "sin entradas",
|
||||
"timeline_letters_count": "{count} cartas",
|
||||
"timeline_strip_expand": "Mostrar cartas",
|
||||
"timeline_range_aria": "Período: {from} a {to}",
|
||||
"timeline_layer_world": "Acontecimientos mundiales",
|
||||
"timeline_layer_family": "Familia",
|
||||
"timeline_derived_birth": "Nacimiento",
|
||||
"timeline_derived_death": "Fallecimiento",
|
||||
"timeline_derived_marriage": "Matrimonio",
|
||||
"event_editor_new_title": "Nuevo evento",
|
||||
"event_editor_edit_title": "Editar evento",
|
||||
"event_editor_section_when": "Cuándo",
|
||||
"event_editor_section_persons": "Personas involucradas",
|
||||
"event_editor_section_documents": "Cartas vinculadas",
|
||||
"event_editor_section_description": "Descripción",
|
||||
"event_editor_title_label": "Título",
|
||||
"event_editor_title_placeholder": "Título del evento",
|
||||
"event_editor_title_required": "Por favor, introduzca un título.",
|
||||
"event_editor_date_required": "Por favor, introduzca una fecha.",
|
||||
"event_editor_type_label": "Tipo",
|
||||
"event_editor_persons_label": "Personas",
|
||||
"event_editor_documents_label": "Cartas",
|
||||
"event_editor_description_label": "Descripción",
|
||||
"event_editor_description_placeholder": "Descripción opcional",
|
||||
"event_editor_persons_empty": "Aún no hay ninguna persona vinculada",
|
||||
"event_editor_documents_empty": "Aún no hay ningún documento vinculado",
|
||||
"event_type_PERSONAL": "Personal",
|
||||
"event_type_HISTORICAL": "Histórico",
|
||||
"event_editor_save": "Guardar",
|
||||
"event_editor_save_hint": "Los eventos aparecen en la cronología.",
|
||||
"event_editor_delete": "Eliminar",
|
||||
"event_editor_delete_confirm_title": "¿Eliminar evento?",
|
||||
"event_editor_delete_confirm_body": "Este evento se eliminará de forma permanente.",
|
||||
"event_editor_unsaved_changes": "Tienes cambios sin guardar — ¿salir de todos modos?",
|
||||
"error_geschichte_not_found": "No se encontró la historia.",
|
||||
"error_journey_item_not_found": "No se encontró el elemento del viaje.",
|
||||
"error_journey_item_position_conflict": "El orden fue cambiado por otra persona — por favor recargue la página.",
|
||||
|
||||
@@ -11,21 +11,12 @@ interface Props {
|
||||
selectedDocuments?: DocumentOption[];
|
||||
placeholder?: string;
|
||||
hiddenInputName?: string;
|
||||
/** Empty-state text shown inside the chip container when nothing is selected. */
|
||||
emptyLabel?: string;
|
||||
/** id of the search input so a <label for=...> can be associated. */
|
||||
inputId?: string;
|
||||
/** Called when the selection changes (add/remove) — lets a parent track dirtiness. */
|
||||
onchange?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
selectedDocuments = $bindable([]),
|
||||
placeholder = m.geschichte_editor_search_document(),
|
||||
hiddenInputName = 'documentIds',
|
||||
emptyLabel = undefined,
|
||||
inputId = undefined,
|
||||
onchange = undefined
|
||||
hiddenInputName = 'documentIds'
|
||||
}: Props = $props();
|
||||
|
||||
let searchTerm = $state('');
|
||||
@@ -57,12 +48,10 @@ function selectDocument(doc: DocumentOption) {
|
||||
selectedDocuments = [...selectedDocuments, doc];
|
||||
searchTerm = '';
|
||||
picker.close();
|
||||
onchange?.();
|
||||
}
|
||||
|
||||
function removeDocument(id: string | undefined) {
|
||||
selectedDocuments = selectedDocuments.filter((d) => d.id !== id);
|
||||
onchange?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -84,7 +73,7 @@ function removeDocument(id: string | undefined) {
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeDocument(doc.id)}
|
||||
class="ml-0.5 inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded-sm text-ink/50 hover:text-red-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
class="ml-0.5 text-ink/50 hover:text-red-500 focus:outline-none"
|
||||
aria-label={m.comp_multiselect_remove()}
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -99,13 +88,8 @@ function removeDocument(id: string | undefined) {
|
||||
</span>
|
||||
{/each}
|
||||
|
||||
{#if emptyLabel && selectedDocuments.length === 0}
|
||||
<span class="px-1 py-1 font-sans text-sm text-ink-3 italic">{emptyLabel}</span>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
id={inputId}
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
bind:value={searchTerm}
|
||||
|
||||
@@ -157,14 +157,4 @@ describe('DocumentMultiSelect — remove', () => {
|
||||
document.querySelector<HTMLInputElement>('input[type="hidden"][name="documentIds"]')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
// REQ-017 (#781): chip remove targets must be ≥44px for the 60+ audience.
|
||||
it('renders a ≥44px touch target on the chip remove button', async () => {
|
||||
render(DocumentMultiSelect, {
|
||||
selectedDocuments: [docFactory('d1', 'Brief A')]
|
||||
});
|
||||
const removeBtn = (await page.getByLabelText('Entfernen').element()) as HTMLElement;
|
||||
expect(removeBtn.className).toContain('min-h-[44px]');
|
||||
expect(removeBtn.className).toContain('min-w-[44px]');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,7 +30,6 @@ Sub-folders: `annotation/`, `transcription/`, `viewer/`.
|
||||
- `tag/TagInput.svelte` — tag chip input
|
||||
- `ocr/OcrProgress.svelte` — job status indicator in the document header
|
||||
- `shared/primitives/BackButton.svelte`, `shared/discussion/` — shared UI
|
||||
- `shared/utils/monthBuckets.ts` — the density chart's pure month-bucket math (boundaries, gap-fill, year aggregation, axis ticks) now lives in `shared/` so the `timeline/` domain can reuse it; `document/timeline.ts` keeps only the `/api/documents/density` glue (`fetchDensity`, `buildDensityUrl`)
|
||||
|
||||
## Backend counterpart
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { formatTickLabel } from '$lib/shared/utils/monthBuckets';
|
||||
import { formatTickLabel } from '$lib/document/timeline';
|
||||
import { getLocale } from '$lib/paraglide/runtime';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
selectionBoundaryFrom,
|
||||
selectionBoundaryTo,
|
||||
formatTickLabel
|
||||
} from '$lib/shared/utils/monthBuckets';
|
||||
} from '$lib/document/timeline';
|
||||
import { createTimelineDrag } from '$lib/document/useTimelineDrag.svelte';
|
||||
import { getLocale } from '$lib/paraglide/runtime';
|
||||
import TimelineBars from '$lib/document/TimelineBars.svelte';
|
||||
|
||||
@@ -3,7 +3,7 @@ import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import { tick } from 'svelte';
|
||||
import TimelineDensityFilter from './TimelineDensityFilter.svelte';
|
||||
import { formatTickLabel } from '$lib/shared/utils/monthBuckets';
|
||||
import { formatTickLabel } from './timeline';
|
||||
import { getLocale } from '$lib/paraglide/runtime';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { formatTickLabel, tickIndicesFor } from '$lib/shared/utils/monthBuckets';
|
||||
import { formatTickLabel, tickIndicesFor } from '$lib/document/timeline';
|
||||
import { getLocale } from '$lib/paraglide/runtime';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
|
||||
import PersonMultiSelect from '$lib/person/PersonMultiSelect.svelte';
|
||||
import FieldLabelBadge from '$lib/shared/primitives/FieldLabelBadge.svelte';
|
||||
import DatePrecisionField from '$lib/shared/primitives/DatePrecisionField.svelte';
|
||||
import { isoToGerman, handleGermanDateInput } from '$lib/shared/utils/date';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import type { DatePrecision } from '$lib/shared/utils/documentDate';
|
||||
@@ -36,6 +37,64 @@ let {
|
||||
hideDate?: boolean;
|
||||
editMode?: boolean;
|
||||
} = $props();
|
||||
|
||||
const PRECISIONS: { value: DatePrecision; label: () => string }[] = [
|
||||
{ value: 'DAY', label: m.date_precision_option_day },
|
||||
{ value: 'MONTH', label: m.date_precision_option_month },
|
||||
{ value: 'SEASON', label: m.date_precision_option_season },
|
||||
{ value: 'YEAR', label: m.date_precision_option_year },
|
||||
{ value: 'RANGE', label: m.date_precision_option_range },
|
||||
{ value: 'APPROX', label: m.date_precision_option_approx },
|
||||
{ value: 'UNKNOWN', label: m.date_precision_option_unknown }
|
||||
];
|
||||
|
||||
const showEndDate = $derived(precision === 'RANGE');
|
||||
|
||||
// dateDisplay seeds from the bindable's value or initialDateIso once at mount
|
||||
// and is then user-driven. onMount runs exactly once, so this never stomps
|
||||
// the parent's dateIso on a later prop change.
|
||||
let dateDisplay = $state('');
|
||||
let dateDirty = $state(false);
|
||||
let endDisplay = $state('');
|
||||
|
||||
onMount(() => {
|
||||
const seed = dateIso || initialDateIso;
|
||||
if (seed) {
|
||||
dateDisplay = isoToGerman(seed);
|
||||
if (!dateIso) dateIso = seed;
|
||||
}
|
||||
if (endDateIso) endDisplay = isoToGerman(endDateIso);
|
||||
});
|
||||
|
||||
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
|
||||
|
||||
// Inline mirror of the server guard (#678). ISO YYYY-MM-DD strings compare
|
||||
// lexicographically, so no Date object is needed. Server stays the gate —
|
||||
// this only surfaces the error early; it never disables Save.
|
||||
const endBeforeStart = $derived(
|
||||
showEndDate && endDateIso !== '' && dateIso !== '' && endDateIso < dateIso
|
||||
);
|
||||
|
||||
function handleDateInput(e: Event) {
|
||||
const result = handleGermanDateInput(e);
|
||||
dateDisplay = result.display;
|
||||
dateIso = result.iso;
|
||||
dateDirty = true;
|
||||
}
|
||||
|
||||
function handleEndDateInput(e: Event) {
|
||||
const result = handleGermanDateInput(e);
|
||||
endDisplay = result.display;
|
||||
endDateIso = result.iso;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const suggested = suggestedDateIso;
|
||||
if (suggested && !untrack(() => dateDirty)) {
|
||||
dateDisplay = isoToGerman(suggested);
|
||||
dateIso = suggested;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
@@ -45,22 +104,79 @@ let {
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||
{#if !hideDate}
|
||||
<!-- Datum + Präzision + Enddatum (shared primitive, #781). The three grid
|
||||
cells slot directly into this grid; testids are forwarded so the
|
||||
existing WhoWhenSection selectors survive the extraction. -->
|
||||
<DatePrecisionField
|
||||
bind:dateIso={dateIso}
|
||||
bind:precision={precision}
|
||||
bind:endDateIso={endDateIso}
|
||||
initialDateIso={initialDateIso}
|
||||
suggestedDateIso={suggestedDateIso}
|
||||
dateInputName="documentDate"
|
||||
endDateInputName="metaDateEnd"
|
||||
dateLabel={m.form_label_date()}
|
||||
dateTestId="who-when-date"
|
||||
precisionTestId="who-when-precision"
|
||||
endDateInnerTestId="who-when-end-date"
|
||||
/>
|
||||
<!-- Datum (required — row 1, col 1) -->
|
||||
<div data-testid="who-when-date">
|
||||
<label for="documentDate" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_date()}*</label
|
||||
>
|
||||
<input
|
||||
id="documentDate"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
value={dateDisplay}
|
||||
oninput={handleDateInput}
|
||||
placeholder={m.form_placeholder_date()}
|
||||
maxlength="10"
|
||||
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm
|
||||
{dateInvalid
|
||||
? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500'
|
||||
: 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
|
||||
aria-describedby={dateInvalid ? 'date-error' : undefined}
|
||||
/>
|
||||
<input type="hidden" name="documentDate" value={dateIso} />
|
||||
{#if dateInvalid}
|
||||
<p id="date-error" class="mt-1 text-xs text-red-600">{m.form_date_error()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Datumsgenauigkeit (precision) -->
|
||||
<div data-testid="who-when-precision">
|
||||
<label for="metaDatePrecision" class="mb-1 block text-sm font-medium text-ink-2">
|
||||
{m.form_label_date_precision()}
|
||||
</label>
|
||||
<select
|
||||
id="metaDatePrecision"
|
||||
name="metaDatePrecision"
|
||||
bind:value={precision}
|
||||
class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{#each PRECISIONS as p (p.value)}
|
||||
<option value={p.value}>{p.label()}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Enddatum: progressive disclosure, revealed only for RANGE, announced politely. -->
|
||||
<div aria-live="polite">
|
||||
{#if showEndDate}
|
||||
<div data-testid="who-when-end-date">
|
||||
<label for="metaDateEnd" class="mb-1 block text-sm font-medium text-ink-2">
|
||||
{m.form_label_date_end()}
|
||||
</label>
|
||||
<input
|
||||
id="metaDateEnd"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
value={endDisplay}
|
||||
oninput={handleEndDateInput}
|
||||
placeholder={m.form_placeholder_date()}
|
||||
maxlength="10"
|
||||
aria-invalid={endBeforeStart ? 'true' : undefined}
|
||||
aria-describedby={endBeforeStart ? 'end-date-error' : undefined}
|
||||
class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm
|
||||
{endBeforeStart
|
||||
? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500'
|
||||
: 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
|
||||
/>
|
||||
{#if endBeforeStart}
|
||||
<!-- Non-colour cue (WCAG 1.4.1): warning glyph + text, not red alone. -->
|
||||
<p id="end-date-error" class="mt-1 text-xs text-red-600">
|
||||
<span aria-hidden="true">⚠ </span>{m.error_invalid_date_range()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<input type="hidden" name="metaDateEnd" value={showEndDate ? endDateIso : ''} />
|
||||
{/if}
|
||||
|
||||
<!-- Absender (required in upload mode — row 1, col 2) -->
|
||||
|
||||
@@ -39,17 +39,4 @@ describe('WhoWhenSection — onMount seeding (Felix B1 fix regression fence)', (
|
||||
const locationInput = document.querySelector('input#location') as HTMLInputElement;
|
||||
expect(locationInput.value).toBe('Berlin');
|
||||
});
|
||||
|
||||
// Regression fence for the DatePrecisionField extraction (#781): the existing
|
||||
// spec covered only date pre-fill / hideDate / location, so the RANGE end-date
|
||||
// reveal had no red signal. This test must stay green across the extraction.
|
||||
it('reveals the end-date field when precision is RANGE', async () => {
|
||||
render(WhoWhenSection, { precision: 'RANGE' });
|
||||
await expect.element(page.getByLabelText('Enddatum')).toBeVisible();
|
||||
});
|
||||
|
||||
it('hides the end-date field when precision is not RANGE', async () => {
|
||||
render(WhoWhenSection, { precision: 'YEAR' });
|
||||
await expect.element(page.getByTestId('who-when-end-date')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,14 +15,14 @@ describe('WhoWhenSection — date input behavior', () => {
|
||||
await vi.waitFor(() => {
|
||||
// Invalid → border-red-400 class
|
||||
expect(dateInput.className).toContain('border-red-400');
|
||||
expect(document.querySelector('#documentDate-error')).not.toBeNull();
|
||||
expect(document.querySelector('#date-error')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show the error before the user has typed', async () => {
|
||||
render(WhoWhenSection, {});
|
||||
|
||||
const error = document.querySelector('#documentDate-error');
|
||||
const error = document.querySelector('#date-error');
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
@@ -77,20 +77,20 @@ describe('WhoWhenSection — precision controls', () => {
|
||||
it('renders a labelled precision select', async () => {
|
||||
render(WhoWhenSection, {});
|
||||
|
||||
const label = document.querySelector('label[for="documentDatePrecision"]');
|
||||
const select = document.querySelector('select#documentDatePrecision[name="metaDatePrecision"]');
|
||||
const label = document.querySelector('label[for="metaDatePrecision"]');
|
||||
const select = document.querySelector('select#metaDatePrecision[name="metaDatePrecision"]');
|
||||
expect(label).not.toBeNull();
|
||||
expect(select).not.toBeNull();
|
||||
});
|
||||
|
||||
it('hides the end-date field unless precision is RANGE', async () => {
|
||||
render(WhoWhenSection, { precision: 'DAY' });
|
||||
expect(document.querySelector('input#documentDateEnd')).toBeNull();
|
||||
expect(document.querySelector('input#metaDateEnd')).toBeNull();
|
||||
});
|
||||
|
||||
it('reveals the end-date field when precision is RANGE', async () => {
|
||||
render(WhoWhenSection, { precision: 'RANGE' });
|
||||
expect(document.querySelector('input#documentDateEnd')).not.toBeNull();
|
||||
expect(document.querySelector('input#metaDateEnd')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('never renders the raw cell, and never re-submits it via a hidden input', async () => {
|
||||
@@ -110,9 +110,9 @@ describe('WhoWhenSection — end-before-start inline validation (#678)', () => {
|
||||
endDateIso: '1917-01-10'
|
||||
});
|
||||
|
||||
const end = document.querySelector('input#documentDateEnd') as HTMLInputElement;
|
||||
const end = document.querySelector('input#metaDateEnd') as HTMLInputElement;
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('#documentDate-end-error')).not.toBeNull();
|
||||
expect(document.querySelector('#end-date-error')).not.toBeNull();
|
||||
expect(end.getAttribute('aria-invalid')).toBe('true');
|
||||
expect(end.className).toContain('border-red-400');
|
||||
});
|
||||
@@ -125,16 +125,14 @@ describe('WhoWhenSection — end-before-start inline validation (#678)', () => {
|
||||
endDateIso: '1917-01-10'
|
||||
});
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(document.querySelector('#documentDate-end-error')).not.toBeNull()
|
||||
);
|
||||
await vi.waitFor(() => expect(document.querySelector('#end-date-error')).not.toBeNull());
|
||||
|
||||
const end = document.querySelector('input#documentDateEnd') as HTMLInputElement;
|
||||
const end = document.querySelector('input#metaDateEnd') as HTMLInputElement;
|
||||
end.value = '12.01.1917'; // now after the start
|
||||
end.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('#documentDate-end-error')).toBeNull();
|
||||
expect(document.querySelector('#end-date-error')).toBeNull();
|
||||
expect(end.getAttribute('aria-invalid')).not.toBe('true');
|
||||
});
|
||||
});
|
||||
@@ -146,6 +144,6 @@ describe('WhoWhenSection — end-before-start inline validation (#678)', () => {
|
||||
endDateIso: '1917-01-10'
|
||||
});
|
||||
|
||||
expect(document.querySelector('#documentDate-end-error')).toBeNull();
|
||||
expect(document.querySelector('#end-date-error')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatDocumentOption, type DocumentOption } from './documentTypeahead';
|
||||
|
||||
describe('formatDocumentOption', () => {
|
||||
it('returns the bare title when no documentDate is present', () => {
|
||||
const doc: DocumentOption = { id: 'd1', title: 'Brief ohne Datum' };
|
||||
expect(formatDocumentOption(doc)).toBe('Brief ohne Datum');
|
||||
});
|
||||
|
||||
// #781: a TimelineEvent's DocumentRef carries documentDate but no precision.
|
||||
// Missing precision must degrade to the full date (DAY), never the UNKNOWN label.
|
||||
it('renders the full date when precision is absent (DocumentRef chip)', () => {
|
||||
const doc: DocumentOption = { id: 'd1', title: 'Umzugsbrief', documentDate: '1925-04-01' };
|
||||
const label = formatDocumentOption(doc);
|
||||
expect(label.startsWith('Umzugsbrief · ')).toBe(true);
|
||||
expect(label).toContain('1925');
|
||||
// The undefined-precision fallback would otherwise surface the UNKNOWN word.
|
||||
expect(label.toLowerCase()).not.toContain('unbekannt');
|
||||
});
|
||||
});
|
||||
@@ -5,21 +5,13 @@ import { getLocale } from '$lib/paraglide/runtime.js';
|
||||
|
||||
type DocumentListItem = components['schemas']['DocumentListItem'];
|
||||
|
||||
/**
|
||||
* Chip/dedup contract for document pickers. `metaDatePrecision`/`metaDateEnd`
|
||||
* are optional: the typeahead always populates them, but a TimelineEvent's
|
||||
* DocumentRef (#781) carries only id/title/documentDate — formatDocumentOption
|
||||
* degrades gracefully (bare title or plain date) when precision is absent.
|
||||
*/
|
||||
export type DocumentOption = Pick<DocumentListItem, 'id' | 'title' | 'documentDate'> &
|
||||
Partial<Pick<DocumentListItem, 'metaDatePrecision' | 'metaDateEnd'>>;
|
||||
export type DocumentOption = Pick<
|
||||
DocumentListItem,
|
||||
'id' | 'title' | 'documentDate' | 'metaDatePrecision' | 'metaDateEnd'
|
||||
>;
|
||||
|
||||
export function createDocumentTypeahead() {
|
||||
return createTypeahead<DocumentOption>({
|
||||
// Intentional bare browser fetch (matches the Geschichte editor): in dev the
|
||||
// Vite proxy forwards /api and injects the auth header; in prod the app is
|
||||
// same-origin so the auth cookie travels automatically. An internal
|
||||
// +server.ts proxy would add complexity with no practical security benefit.
|
||||
fetchUrl: (q) =>
|
||||
fetch(`/api/documents/search?q=${encodeURIComponent(q)}&size=10`)
|
||||
.then((r) => {
|
||||
@@ -42,12 +34,9 @@ export function createDocumentTypeahead() {
|
||||
|
||||
export function formatDocumentOption(doc: DocumentOption): string {
|
||||
if (!doc.documentDate) return doc.title;
|
||||
// A DocumentRef (#781 timeline chips) carries documentDate but no precision —
|
||||
// default to DAY so the full date renders, rather than the UNKNOWN fallback
|
||||
// formatDocumentDate would otherwise hit for an undefined precision.
|
||||
const label = formatDocumentDate(
|
||||
doc.documentDate,
|
||||
(doc.metaDatePrecision as DatePrecision) ?? 'DAY',
|
||||
doc.metaDatePrecision as DatePrecision,
|
||||
doc.metaDateEnd,
|
||||
null,
|
||||
getLocale()
|
||||
|
||||
@@ -1,5 +1,191 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { fetchDensity, buildDensityUrl } from './timeline';
|
||||
import {
|
||||
monthBoundaryFrom,
|
||||
monthBoundaryTo,
|
||||
buildMonthSequence,
|
||||
fillDensityGaps,
|
||||
fetchDensity,
|
||||
buildDensityUrl,
|
||||
aggregateToYears,
|
||||
selectionBoundaryFrom,
|
||||
selectionBoundaryTo,
|
||||
clipBucketsToRange,
|
||||
tickIndicesFor,
|
||||
formatTickLabel
|
||||
} from './timeline';
|
||||
|
||||
describe('monthBoundaryFrom', () => {
|
||||
it('returns the first day of the given month', () => {
|
||||
expect(monthBoundaryFrom('1915-08')).toBe('1915-08-01');
|
||||
});
|
||||
|
||||
it('handles January', () => {
|
||||
expect(monthBoundaryFrom('1920-01')).toBe('1920-01-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('monthBoundaryTo', () => {
|
||||
it('returns the last day of a 31-day month', () => {
|
||||
expect(monthBoundaryTo('1915-08')).toBe('1915-08-31');
|
||||
});
|
||||
|
||||
it('returns the last day of a 30-day month', () => {
|
||||
expect(monthBoundaryTo('1915-04')).toBe('1915-04-30');
|
||||
});
|
||||
|
||||
it('returns 28 for February in a non-leap year', () => {
|
||||
expect(monthBoundaryTo('1915-02')).toBe('1915-02-28');
|
||||
});
|
||||
|
||||
it('returns 29 for February in a leap year', () => {
|
||||
expect(monthBoundaryTo('1916-02')).toBe('1916-02-29');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMonthSequence', () => {
|
||||
it('returns a single month when min and max are in the same month', () => {
|
||||
expect(buildMonthSequence('1915-08-03', '1915-08-22')).toEqual(['1915-08']);
|
||||
});
|
||||
|
||||
it('returns months from minDate through maxDate inclusive', () => {
|
||||
expect(buildMonthSequence('1915-08-03', '1915-11-15')).toEqual([
|
||||
'1915-08',
|
||||
'1915-09',
|
||||
'1915-10',
|
||||
'1915-11'
|
||||
]);
|
||||
});
|
||||
|
||||
it('crosses year boundaries correctly', () => {
|
||||
expect(buildMonthSequence('1915-11-30', '1916-02-01')).toEqual([
|
||||
'1915-11',
|
||||
'1915-12',
|
||||
'1916-01',
|
||||
'1916-02'
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array when minDate or maxDate is null', () => {
|
||||
expect(buildMonthSequence(null, '1915-08-01')).toEqual([]);
|
||||
expect(buildMonthSequence('1915-08-01', null)).toEqual([]);
|
||||
expect(buildMonthSequence(null, null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fillDensityGaps', () => {
|
||||
it('returns empty array when minDate or maxDate is null', () => {
|
||||
expect(fillDensityGaps([], null, null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves existing buckets and adds zero-count buckets for missing months', () => {
|
||||
const buckets = [
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-11', count: 2 }
|
||||
];
|
||||
|
||||
const result = fillDensityGaps(buckets, '1915-08-03', '1915-11-30');
|
||||
|
||||
expect(result).toEqual([
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-09', count: 0 },
|
||||
{ month: '1915-10', count: 0 },
|
||||
{ month: '1915-11', count: 2 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns all-zero sequence when buckets array is empty', () => {
|
||||
const result = fillDensityGaps([], '1915-08-03', '1915-10-15');
|
||||
|
||||
expect(result).toEqual([
|
||||
{ month: '1915-08', count: 0 },
|
||||
{ month: '1915-09', count: 0 },
|
||||
{ month: '1915-10', count: 0 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps results sorted chronologically even when buckets arrive out of order', () => {
|
||||
const buckets = [
|
||||
{ month: '1915-10', count: 3 },
|
||||
{ month: '1915-08', count: 1 }
|
||||
];
|
||||
|
||||
const result = fillDensityGaps(buckets, '1915-08-01', '1915-10-31');
|
||||
|
||||
expect(result.map((b) => b.month)).toEqual(['1915-08', '1915-09', '1915-10']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateToYears', () => {
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(aggregateToYears([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('sums counts within the same year', () => {
|
||||
const result = aggregateToYears([
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 8 }
|
||||
]);
|
||||
expect(result).toEqual([{ month: '1915', count: 15 }]);
|
||||
});
|
||||
|
||||
it('produces one bucket per distinct year, sorted chronologically', () => {
|
||||
const result = aggregateToYears([
|
||||
{ month: '1916-01', count: 3 },
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1916-04', count: 7 },
|
||||
{ month: '1914-12', count: 1 }
|
||||
]);
|
||||
expect(result).toEqual([
|
||||
{ month: '1914', count: 1 },
|
||||
{ month: '1915', count: 5 },
|
||||
{ month: '1916', count: 10 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clipBucketsToRange', () => {
|
||||
const buckets = [
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 8 },
|
||||
{ month: '1915-11', count: 3 }
|
||||
];
|
||||
|
||||
it('returns the original buckets when range bounds are null', () => {
|
||||
expect(clipBucketsToRange(buckets, null, null)).toBe(buckets);
|
||||
});
|
||||
|
||||
it('keeps only buckets whose month falls within the range', () => {
|
||||
expect(clipBucketsToRange(buckets, '1915-09-01', '1915-10-31')).toEqual([
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 8 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty array when the range excludes everything', () => {
|
||||
expect(clipBucketsToRange(buckets, '1916-01-01', '1916-12-31')).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats partial dates correctly when bounds cross month boundaries', () => {
|
||||
expect(clipBucketsToRange(buckets, '1915-09-15', '1915-10-15')).toEqual([
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 8 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectionBoundaryFrom / To', () => {
|
||||
it('handles month labels (YYYY-MM)', () => {
|
||||
expect(selectionBoundaryFrom('1915-08')).toBe('1915-08-01');
|
||||
expect(selectionBoundaryTo('1915-08')).toBe('1915-08-31');
|
||||
});
|
||||
|
||||
it('handles year labels (YYYY)', () => {
|
||||
expect(selectionBoundaryFrom('1915')).toBe('1915-01-01');
|
||||
expect(selectionBoundaryTo('1915')).toBe('1915-12-31');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDensityUrl', () => {
|
||||
it('returns the bare endpoint when no filters provided', () => {
|
||||
@@ -123,3 +309,84 @@ describe('fetchDensity', () => {
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tickIndicesFor', () => {
|
||||
it('returns no indices for an empty bucket list', () => {
|
||||
expect(tickIndicesFor([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('picks years divisible by 25 when the year span exceeds 120', () => {
|
||||
const buckets = Array.from({ length: 150 }, (_, i) => ({
|
||||
month: String(1875 + i),
|
||||
count: 1
|
||||
}));
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
const labels = ticks.map((i) => buckets[i].month);
|
||||
expect(labels).toEqual(['1875', '1900', '1925', '1950', '1975', '2000']);
|
||||
});
|
||||
|
||||
it('picks years divisible by 10 for medium ranges (~50 years)', () => {
|
||||
const buckets = Array.from({ length: 50 }, (_, i) => ({
|
||||
month: String(1900 + i),
|
||||
count: 1
|
||||
}));
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
const labels = ticks.map((i) => buckets[i].month);
|
||||
expect(labels).toEqual(['1900', '1910', '1920', '1930', '1940']);
|
||||
});
|
||||
|
||||
it('picks January boundaries for long month ranges', () => {
|
||||
const buckets = [
|
||||
{ month: '1914-08', count: 1 },
|
||||
{ month: '1914-09', count: 1 },
|
||||
{ month: '1914-10', count: 1 },
|
||||
{ month: '1914-11', count: 1 },
|
||||
{ month: '1914-12', count: 1 },
|
||||
{ month: '1915-01', count: 1 },
|
||||
{ month: '1915-02', count: 1 },
|
||||
{ month: '1915-03', count: 1 },
|
||||
{ month: '1915-04', count: 1 },
|
||||
{ month: '1915-05', count: 1 },
|
||||
{ month: '1915-06', count: 1 },
|
||||
{ month: '1915-07', count: 1 },
|
||||
{ month: '1915-08', count: 1 },
|
||||
{ month: '1915-09', count: 1 },
|
||||
{ month: '1915-10', count: 1 },
|
||||
{ month: '1915-11', count: 1 },
|
||||
{ month: '1915-12', count: 1 },
|
||||
{ month: '1916-01', count: 1 },
|
||||
{ month: '1916-02', count: 1 }
|
||||
];
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
expect(ticks.map((i) => buckets[i].month)).toEqual(['1915-01', '1916-01']);
|
||||
});
|
||||
|
||||
it('falls back to evenly spaced ticks for short month ranges (12 months)', () => {
|
||||
const buckets = Array.from({ length: 12 }, (_, i) => ({
|
||||
month: `1905-${String(i + 1).padStart(2, '0')}`,
|
||||
count: 1
|
||||
}));
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
expect(ticks.length).toBeGreaterThanOrEqual(5);
|
||||
expect(ticks.length).toBeLessThanOrEqual(7);
|
||||
expect(ticks[0]).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTickLabel', () => {
|
||||
it('returns the year string unchanged for year labels', () => {
|
||||
expect(formatTickLabel('1905', 'en-US')).toBe('1905');
|
||||
});
|
||||
|
||||
it('formats month labels with the year by default', () => {
|
||||
const result = formatTickLabel('1905-06', 'en-US');
|
||||
expect(result).toMatch(/Jun/);
|
||||
expect(result).toMatch(/1905/);
|
||||
});
|
||||
|
||||
it('omits the year when omitYear is true', () => {
|
||||
const result = formatTickLabel('1905-06', 'en-US', true);
|
||||
expect(result).toMatch(/Jun/);
|
||||
expect(result).not.toMatch(/1905/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,160 @@ export type DensityState = {
|
||||
const SKIP: DensityState = { density: null, minDate: null, maxDate: null };
|
||||
const EMPTY: DensityState = { density: [], minDate: null, maxDate: null };
|
||||
|
||||
export function monthBoundaryFrom(yearMonth: string): string {
|
||||
return `${yearMonth}-01`;
|
||||
}
|
||||
|
||||
export function monthBoundaryTo(yearMonth: string): string {
|
||||
const [year, month] = yearMonth.split('-').map(Number);
|
||||
// Day 0 of `month + 1` rolls back to the last day of `month` — so passing
|
||||
// `month` (1-indexed) into `Date.UTC(year, month, 0)` lands on the last day
|
||||
// of that month. Handles 28/29/30/31 and leap years without a lookup table.
|
||||
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
return `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function buildMonthSequence(minDate: string | null, maxDate: string | null): string[] {
|
||||
if (!minDate || !maxDate) return [];
|
||||
|
||||
const [minY, minM] = minDate.split('-').map(Number);
|
||||
const [maxY, maxM] = maxDate.split('-').map(Number);
|
||||
|
||||
const sequence: string[] = [];
|
||||
let year = minY;
|
||||
let month = minM;
|
||||
|
||||
while (year < maxY || (year === maxY && month <= maxM)) {
|
||||
sequence.push(`${year}-${String(month).padStart(2, '0')}`);
|
||||
month += 1;
|
||||
if (month > 12) {
|
||||
month = 1;
|
||||
year += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return sequence;
|
||||
}
|
||||
|
||||
export function fillDensityGaps(
|
||||
buckets: MonthBucket[],
|
||||
minDate: string | null,
|
||||
maxDate: string | null
|
||||
): MonthBucket[] {
|
||||
const sequence = buildMonthSequence(minDate, maxDate);
|
||||
if (sequence.length === 0) return [];
|
||||
|
||||
const counts = new Map(buckets.map((b) => [b.month, b.count]));
|
||||
return sequence.map((month) => ({ month, count: counts.get(month) ?? 0 }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns only the month buckets whose YYYY-MM falls inside the provided
|
||||
* `[fromInclusive, toInclusive]` ISO date range. When either bound is null the
|
||||
* input array is returned unchanged. Used by the timeline's zoom-in tool to
|
||||
* narrow the visible bars without refetching data.
|
||||
*
|
||||
* @internal Sole call site is `TimelineDensityFilter.svelte`. Exported so the
|
||||
* unit suite (`timeline.spec.ts`) can pin the boundary semantics directly.
|
||||
*/
|
||||
export function clipBucketsToRange(
|
||||
buckets: MonthBucket[],
|
||||
fromInclusive: string | null,
|
||||
toInclusive: string | null
|
||||
): MonthBucket[] {
|
||||
if (!fromInclusive || !toInclusive) return buckets;
|
||||
const fromMonth = fromInclusive.slice(0, 7);
|
||||
const toMonth = toInclusive.slice(0, 7);
|
||||
return buckets.filter((b) => b.month >= fromMonth && b.month <= toMonth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates month-granular buckets into one entry per year. Month strings are
|
||||
* truncated to "YYYY" and counts are summed. Used when the date span is too
|
||||
* long for month-granular bars to render at a clickable size.
|
||||
*/
|
||||
export function aggregateToYears(buckets: MonthBucket[]): MonthBucket[] {
|
||||
const totals = new Map<string, number>();
|
||||
for (const b of buckets) {
|
||||
const year = b.month.slice(0, 4);
|
||||
totals.set(year, (totals.get(year) ?? 0) + b.count);
|
||||
}
|
||||
return Array.from(totals.entries())
|
||||
.map(([year, count]) => ({ month: year, count }))
|
||||
.sort((a, b) => a.month.localeCompare(b.month));
|
||||
}
|
||||
|
||||
/**
|
||||
* Boundary helpers for selection. Accept either "YYYY-MM" (month) or "YYYY"
|
||||
* (year) and return the matching LocalDate string.
|
||||
*/
|
||||
export function selectionBoundaryFrom(label: string): string {
|
||||
return label.length === 4 ? `${label}-01-01` : `${label}-01`;
|
||||
}
|
||||
|
||||
export function selectionBoundaryTo(label: string): string {
|
||||
if (label.length === 4) return `${label}-12-31`;
|
||||
return monthBoundaryTo(label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks bucket indices that should get an X-axis tick label. The strategy adapts
|
||||
* to whether bars are years or months and how many are visible:
|
||||
* - Year bars: pick years divisible by a step that scales with range length
|
||||
* (every 25 yrs for >120 bars, every 20 / 10 / 5 / 1 below).
|
||||
* - Month bars: prefer January boundaries (year breaks). For ≤18 bars (e.g.
|
||||
* one year zoomed in to months), fall back to evenly spaced ticks so we
|
||||
* show ~6 labels even when no January boundary exists.
|
||||
*/
|
||||
export function tickIndicesFor(filled: MonthBucket[]): number[] {
|
||||
if (filled.length === 0) return [];
|
||||
const isYearMode = filled[0].month.length === 4;
|
||||
const indices: number[] = [];
|
||||
|
||||
if (isYearMode) {
|
||||
const years = filled.length;
|
||||
const step =
|
||||
years > 120 ? 25 : years > 60 ? 20 : years > 30 ? 10 : years > 12 ? 5 : years > 6 ? 2 : 1;
|
||||
for (let i = 0; i < filled.length; i++) {
|
||||
const year = parseInt(filled[i].month, 10);
|
||||
if (year % step === 0) indices.push(i);
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
|
||||
if (filled.length <= 18) {
|
||||
const step = Math.max(1, Math.round(filled.length / 6));
|
||||
for (let i = 0; i < filled.length; i += step) indices.push(i);
|
||||
return indices;
|
||||
}
|
||||
|
||||
// Long month range — pick January boundaries (year breaks).
|
||||
for (let i = 0; i < filled.length; i++) {
|
||||
if (filled[i].month.endsWith('-01')) indices.push(i);
|
||||
}
|
||||
// Fallback if there's no January in the visible range (rare): even spacing.
|
||||
if (indices.length === 0) {
|
||||
const step = Math.max(1, Math.round(filled.length / 6));
|
||||
for (let i = 0; i < filled.length; i += step) indices.push(i);
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a bucket month label ("YYYY" or "YYYY-MM") for the X-axis. When
|
||||
* `omitYear` is true the year is dropped so a 12-month zoomed view reads as
|
||||
* "Jan", "Feb", … without repetition.
|
||||
*/
|
||||
export function formatTickLabel(label: string, locale?: string, omitYear = false): string {
|
||||
if (label.length === 4) return label;
|
||||
const [yearStr, monthStr] = label.split('-');
|
||||
const date = new Date(parseInt(yearStr, 10), parseInt(monthStr, 10) - 1, 1);
|
||||
const opts: Intl.DateTimeFormatOptions = omitYear
|
||||
? { month: 'short' }
|
||||
: { month: 'short', year: 'numeric' };
|
||||
return new Intl.DateTimeFormat(locale, opts).format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset of /documents URL params that should narrow the density chart.
|
||||
* Date bounds (`from`/`to`) are intentionally excluded — see
|
||||
|
||||
@@ -1032,22 +1032,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/timeline": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getTimeline"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/tags": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2429,38 +2413,6 @@ export interface components {
|
||||
contributors: components["schemas"]["ActivityActorDTO"][];
|
||||
hasMoreContributors: boolean;
|
||||
};
|
||||
TimelineDTO: {
|
||||
years: components["schemas"]["TimelineYearDTO"][];
|
||||
undated: components["schemas"]["TimelineEntryDTO"][];
|
||||
};
|
||||
TimelineEntryDTO: {
|
||||
/** @enum {string} */
|
||||
kind: "EVENT" | "LETTER";
|
||||
/** @enum {string} */
|
||||
precision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
|
||||
derived: boolean;
|
||||
senderName: string;
|
||||
receiverName: string;
|
||||
/** Format: date */
|
||||
eventDate?: string;
|
||||
/** Format: date */
|
||||
eventDateEnd?: string;
|
||||
title?: string;
|
||||
/** @enum {string} */
|
||||
type?: "PERSONAL" | "HISTORICAL";
|
||||
/** Format: uuid */
|
||||
eventId?: string;
|
||||
/** Format: uuid */
|
||||
documentId?: string;
|
||||
linkedPersonIds?: string[];
|
||||
/** @enum {string} */
|
||||
derivedType?: "BIRTH" | "DEATH" | "MARRIAGE";
|
||||
};
|
||||
TimelineYearDTO: {
|
||||
/** Format: int32 */
|
||||
year: number;
|
||||
entries: components["schemas"]["TimelineEntryDTO"][];
|
||||
};
|
||||
TagTreeNodeDTO: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
@@ -2516,10 +2468,10 @@ export interface components {
|
||||
birthDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
|
||||
/** Format: date */
|
||||
deathDate?: string;
|
||||
personType?: string;
|
||||
familyMember?: boolean;
|
||||
/** @enum {string} */
|
||||
deathDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
|
||||
personType?: string;
|
||||
familyMember?: boolean;
|
||||
provisional?: boolean;
|
||||
/** Format: int32 */
|
||||
birthYear?: number;
|
||||
@@ -5041,32 +4993,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getTimeline: {
|
||||
parameters: {
|
||||
query?: {
|
||||
personId?: string;
|
||||
generation?: number;
|
||||
type?: "PERSONAL" | "HISTORICAL";
|
||||
fromYear?: number;
|
||||
toYear?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["TimelineDTO"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
searchTags: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
@@ -40,32 +40,4 @@ describe('message key parity', () => {
|
||||
expect(es).toHaveProperty('layout_menu_open');
|
||||
expect(es).toHaveProperty('layout_menu_close');
|
||||
});
|
||||
|
||||
// REQ-024: the timeline layer/life-event labels feed sr-only / aria text, so
|
||||
// they are localized per locale (the original German-only MVP decision was
|
||||
// reversed for accessibility). Pin the values so en/es can never silently
|
||||
// drift back to the German source strings.
|
||||
it('timeline layer/derived labels are localized per locale (REQ-024)', () => {
|
||||
expect(de).toMatchObject({
|
||||
timeline_layer_world: 'Weltgeschehen',
|
||||
timeline_layer_family: 'Familie',
|
||||
timeline_derived_birth: 'Geburt',
|
||||
timeline_derived_death: 'Tod',
|
||||
timeline_derived_marriage: 'Heirat'
|
||||
});
|
||||
expect(en).toMatchObject({
|
||||
timeline_layer_world: 'World events',
|
||||
timeline_layer_family: 'Family',
|
||||
timeline_derived_birth: 'Birth',
|
||||
timeline_derived_death: 'Death',
|
||||
timeline_derived_marriage: 'Marriage'
|
||||
});
|
||||
expect(es).toMatchObject({
|
||||
timeline_layer_world: 'Acontecimientos mundiales',
|
||||
timeline_layer_family: 'Familia',
|
||||
timeline_derived_birth: 'Nacimiento',
|
||||
timeline_derived_death: 'Fallecimiento',
|
||||
timeline_derived_marriage: 'Matrimonio'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,23 +7,9 @@ type Person = components['schemas']['Person'];
|
||||
|
||||
interface Props {
|
||||
selectedPersons?: PersonOption[];
|
||||
/** Name of the hidden inputs carrying selected ids. Mirrors DocumentMultiSelect. */
|
||||
hiddenInputName?: string;
|
||||
/** Empty-state text shown inside the chip container when nothing is selected. */
|
||||
emptyLabel?: string;
|
||||
/** id of the search input so a <label for=...> can be associated. */
|
||||
inputId?: string;
|
||||
/** Called when the selection changes (add/remove) — lets a parent track dirtiness. */
|
||||
onchange?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
selectedPersons = $bindable([]),
|
||||
hiddenInputName = 'receiverIds',
|
||||
emptyLabel = undefined,
|
||||
inputId = undefined,
|
||||
onchange = undefined
|
||||
}: Props = $props();
|
||||
let { selectedPersons = $bindable([]) }: Props = $props();
|
||||
|
||||
let searchTerm = $state('');
|
||||
let results: Person[] = $state([]);
|
||||
@@ -68,19 +54,17 @@ function selectPerson(person: Person) {
|
||||
searchTerm = '';
|
||||
showDropdown = false;
|
||||
results = [];
|
||||
onchange?.();
|
||||
}
|
||||
|
||||
function removePerson(id: string | undefined) {
|
||||
selectedPersons = selectedPersons.filter((p) => p.id !== id);
|
||||
onchange?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
||||
|
||||
{#each selectedPersons as person (person.id)}
|
||||
<input type="hidden" name={hiddenInputName} value={person.id} />
|
||||
<input type="hidden" name="receiverIds" value={person.id} />
|
||||
{/each}
|
||||
|
||||
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
||||
@@ -95,7 +79,7 @@ function removePerson(id: string | undefined) {
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removePerson(person.id)}
|
||||
class="ml-0.5 inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded-sm text-ink/50 hover:text-red-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
class="ml-0.5 text-ink/50 hover:text-red-500 focus:outline-none"
|
||||
aria-label={m.comp_multiselect_remove()}
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -110,13 +94,8 @@ function removePerson(id: string | undefined) {
|
||||
</span>
|
||||
{/each}
|
||||
|
||||
{#if emptyLabel && selectedPersons.length === 0}
|
||||
<span class="px-1 py-1 font-sans text-sm text-ink-3 italic">{emptyLabel}</span>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
id={inputId}
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
bind:value={searchTerm}
|
||||
|
||||
@@ -258,19 +258,6 @@ describe('PersonMultiSelect – removing persons', () => {
|
||||
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// REQ-017 (#781): chip remove targets must be ≥44px for the 60+ audience.
|
||||
it('renders a ≥44px touch target on the chip remove button', async () => {
|
||||
render(PersonMultiSelect, {
|
||||
selectedPersons: [{ id: '1', displayName: 'Max Mustermann' }]
|
||||
});
|
||||
const removeBtn = (await page
|
||||
.getByRole('button', { name: 'Entfernen' })
|
||||
.first()
|
||||
.element()) as HTMLElement;
|
||||
expect(removeBtn.className).toContain('min-h-[44px]');
|
||||
expect(removeBtn.className).toContain('min-w-[44px]');
|
||||
});
|
||||
|
||||
it('removes the corresponding hidden input when a chip is removed', async () => {
|
||||
render(PersonMultiSelect, {
|
||||
selectedPersons: [
|
||||
|
||||
@@ -14,23 +14,21 @@ If any condition fails, the file belongs in the domain folder of its primary con
|
||||
|
||||
## What this folder owns
|
||||
|
||||
| Sub-folder / file | Purpose |
|
||||
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `api.server.ts` | Typed `openapi-fetch` client factory — the standard entry point for all backend API calls in server-side load functions and actions |
|
||||
| `errors.ts` | Mirror of the backend `ErrorCode` enum + `getErrorMessage()` → Paraglide i18n key mapping |
|
||||
| `types.ts` | Cross-domain TypeScript interfaces |
|
||||
| `utils.ts` | Pure utility functions (date formatting, sorting, debounce) |
|
||||
| `utils/monthBuckets.ts` | Pure month-bucket math (boundaries, sequences, gap-fill, year aggregation, axis ticks) shared by the `document/` density chart and the `timeline/` density strip — moved up from `document/timeline.ts` so `timeline/` need not import `document/` |
|
||||
| `primitives/Sparkline.svelte` | Fixed-series bar sparkline (one bar per value) — used by the timeline density strip |
|
||||
| `relativeTime.ts` | Human-relative time formatting (`"2 days ago"`) |
|
||||
| `primitives/` | Generic UI components: `BackButton.svelte`, form inputs, pagination, layout shells |
|
||||
| `discussion/` | Comment/mention editor shared by `document/` and `geschichte/` |
|
||||
| `dashboard/` | Family Pulse widget and recent-activity components assembled in the `/` route |
|
||||
| `hooks/` | Svelte 5 reactive hooks: `useTypeahead`, `useUnsavedWarning` |
|
||||
| `services/` | Generic client-side service helpers |
|
||||
| `actions/` | Shared SvelteKit form action utilities |
|
||||
| `server/` | Server-only shared utilities (load function helpers) |
|
||||
| `help/` | Coach marks and empty-state components used across multiple domains |
|
||||
| Sub-folder / file | Purpose |
|
||||
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `api.server.ts` | Typed `openapi-fetch` client factory — the standard entry point for all backend API calls in server-side load functions and actions |
|
||||
| `errors.ts` | Mirror of the backend `ErrorCode` enum + `getErrorMessage()` → Paraglide i18n key mapping |
|
||||
| `types.ts` | Cross-domain TypeScript interfaces |
|
||||
| `utils.ts` | Pure utility functions (date formatting, sorting, debounce) |
|
||||
| `relativeTime.ts` | Human-relative time formatting (`"2 days ago"`) |
|
||||
| `primitives/` | Generic UI components: `BackButton.svelte`, form inputs, pagination, layout shells |
|
||||
| `discussion/` | Comment/mention editor shared by `document/` and `geschichte/` |
|
||||
| `dashboard/` | Family Pulse widget and recent-activity components assembled in the `/` route |
|
||||
| `hooks/` | Svelte 5 reactive hooks: `useTypeahead`, `useUnsavedWarning` |
|
||||
| `services/` | Generic client-side service helpers |
|
||||
| `actions/` | Shared SvelteKit form action utilities |
|
||||
| `server/` | Server-only shared utilities (load function helpers) |
|
||||
| `help/` | Coach marks and empty-state components used across multiple domains |
|
||||
|
||||
## What does NOT belong here
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ $effect(() => {
|
||||
<dialog
|
||||
bind:this={dialogEl}
|
||||
class="m-auto w-full max-w-sm rounded-sm border border-line bg-surface p-6 shadow-lg backdrop:bg-black/50"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-title"
|
||||
oncancel={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { isoToGerman, handleGermanDateInput } from '$lib/shared/utils/date';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { DatePrecision } from '$lib/shared/utils/documentDate';
|
||||
|
||||
/**
|
||||
* Generic date + precision input primitive shared by two domains:
|
||||
* `document/` (via WhoWhenSection) and `timeline/` (via EventForm).
|
||||
*
|
||||
* Renders three grid cells — a German `dd.mm.yyyy` text input backed by a hidden
|
||||
* ISO input, a precision <select>, and a progressively-disclosed end-date input
|
||||
* shown only for RANGE. Living in `$lib/shared/primitives/` keeps it out of either
|
||||
* consumer's domain so neither incurs a cross-domain import (eslint boundaries).
|
||||
*
|
||||
* Exposed (shared contract — both WhoWhenSection and EventForm depend on it):
|
||||
* - dateIso, precision, endDateIso — $bindable; the parent's binding IS the
|
||||
* state (no redundant $state mirror).
|
||||
* - dateInputName / endDateInputName / precisionInputName — submitted field
|
||||
* names; defaults match the document form (`metaDatePrecision`), the timeline
|
||||
* form overrides precisionInputName to `precision`.
|
||||
* - initialDateIso / suggestedDateIso — seeding inputs (see onMount + $effect).
|
||||
* - dateTestId / precisionTestId / endDateInnerTestId — forwarded data-testid
|
||||
* attributes so existing WhoWhenSection selectors survive the extraction.
|
||||
* - `end-date-region` is always on the OUTER aria-live wrapper of the end block.
|
||||
*/
|
||||
let {
|
||||
dateIso = $bindable(''),
|
||||
precision = $bindable<DatePrecision>('DAY'),
|
||||
endDateIso = $bindable(''),
|
||||
dateInputName = 'documentDate',
|
||||
endDateInputName = 'metaDateEnd',
|
||||
precisionInputName = 'metaDatePrecision',
|
||||
initialDateIso = '',
|
||||
suggestedDateIso = '',
|
||||
dateLabel = m.form_label_date(),
|
||||
dateRequired = true,
|
||||
dateError = '',
|
||||
onchange = undefined,
|
||||
dateTestId = undefined,
|
||||
precisionTestId = undefined,
|
||||
endDateInnerTestId = undefined
|
||||
}: {
|
||||
dateIso?: string;
|
||||
precision?: DatePrecision;
|
||||
endDateIso?: string;
|
||||
dateInputName?: string;
|
||||
endDateInputName?: string;
|
||||
precisionInputName?: string;
|
||||
initialDateIso?: string;
|
||||
suggestedDateIso?: string;
|
||||
dateLabel?: string;
|
||||
dateRequired?: boolean;
|
||||
/** Server-side date error (e.g. blank required field) wired to the field's aria-invalid. */
|
||||
dateError?: string;
|
||||
/** Called on any user edit (date, precision, end-date) — lets a parent track dirtiness. */
|
||||
onchange?: () => void;
|
||||
dateTestId?: string;
|
||||
precisionTestId?: string;
|
||||
endDateInnerTestId?: string;
|
||||
} = $props();
|
||||
|
||||
const PRECISIONS: { value: DatePrecision; label: () => string }[] = [
|
||||
{ value: 'DAY', label: m.date_precision_option_day },
|
||||
{ value: 'MONTH', label: m.date_precision_option_month },
|
||||
{ value: 'SEASON', label: m.date_precision_option_season },
|
||||
{ value: 'YEAR', label: m.date_precision_option_year },
|
||||
{ value: 'RANGE', label: m.date_precision_option_range },
|
||||
{ value: 'APPROX', label: m.date_precision_option_approx },
|
||||
{ value: 'UNKNOWN', label: m.date_precision_option_unknown }
|
||||
];
|
||||
|
||||
const showEndDate = $derived(precision === 'RANGE');
|
||||
|
||||
// dateDisplay seeds from the bindable's value or initialDateIso once at mount
|
||||
// and is then user-driven. onMount runs exactly once, so this never stomps
|
||||
// the parent's dateIso on a later prop change.
|
||||
let dateDisplay = $state('');
|
||||
let dateDirty = $state(false);
|
||||
let endDisplay = $state('');
|
||||
|
||||
onMount(() => {
|
||||
const seed = dateIso || initialDateIso;
|
||||
if (seed) {
|
||||
dateDisplay = isoToGerman(seed);
|
||||
if (!dateIso) dateIso = seed;
|
||||
}
|
||||
if (endDateIso) endDisplay = isoToGerman(endDateIso);
|
||||
});
|
||||
|
||||
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
|
||||
// Either the client-side malformed-date cue or a server-provided required-field
|
||||
// error marks the field invalid (REQ-011 per-field aria-invalid).
|
||||
const dateFieldInvalid = $derived(dateInvalid || dateError.length > 0);
|
||||
|
||||
// Inline mirror of the server guard (#678). ISO YYYY-MM-DD strings compare
|
||||
// lexicographically, so no Date object is needed. Server stays the gate —
|
||||
// this only surfaces the error early; it never disables Save.
|
||||
const endBeforeStart = $derived(
|
||||
showEndDate && endDateIso !== '' && dateIso !== '' && endDateIso < dateIso
|
||||
);
|
||||
|
||||
function handleDateInput(e: Event) {
|
||||
const result = handleGermanDateInput(e);
|
||||
dateDisplay = result.display;
|
||||
dateIso = result.iso;
|
||||
dateDirty = true;
|
||||
onchange?.();
|
||||
}
|
||||
|
||||
function handleEndDateInput(e: Event) {
|
||||
const result = handleGermanDateInput(e);
|
||||
endDisplay = result.display;
|
||||
endDateIso = result.iso;
|
||||
onchange?.();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const suggested = suggestedDateIso;
|
||||
if (suggested && !untrack(() => dateDirty)) {
|
||||
dateDisplay = isoToGerman(suggested);
|
||||
dateIso = suggested;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Datum (required) -->
|
||||
<div data-testid={dateTestId}>
|
||||
<label for={dateInputName} class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{dateLabel}{#if dateRequired}*{/if}</label
|
||||
>
|
||||
<input
|
||||
id={dateInputName}
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
value={dateDisplay}
|
||||
oninput={handleDateInput}
|
||||
placeholder={m.form_placeholder_date()}
|
||||
maxlength="10"
|
||||
aria-required={dateRequired ? 'true' : undefined}
|
||||
class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm
|
||||
{dateFieldInvalid
|
||||
? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500'
|
||||
: 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
|
||||
aria-invalid={dateFieldInvalid ? 'true' : undefined}
|
||||
aria-describedby={dateFieldInvalid ? `${dateInputName}-error` : undefined}
|
||||
/>
|
||||
<input type="hidden" name={dateInputName} value={dateIso} />
|
||||
{#if dateInvalid}
|
||||
<p id="{dateInputName}-error" class="mt-1 text-xs text-danger">
|
||||
<span aria-hidden="true">⚠ </span>{m.form_date_error()}
|
||||
</p>
|
||||
{:else if dateError}
|
||||
<p id="{dateInputName}-error" class="mt-1 text-xs text-danger">
|
||||
<span aria-hidden="true">⚠ </span>{dateError}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Datumsgenauigkeit (precision) -->
|
||||
<div data-testid={precisionTestId}>
|
||||
<label for="{dateInputName}Precision" class="mb-1 block text-sm font-medium text-ink-2">
|
||||
{m.form_label_date_precision()}
|
||||
</label>
|
||||
<select
|
||||
id="{dateInputName}Precision"
|
||||
name={precisionInputName}
|
||||
bind:value={precision}
|
||||
onchange={() => onchange?.()}
|
||||
class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{#each PRECISIONS as p (p.value)}
|
||||
<option value={p.value}>{p.label()}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Enddatum: progressive disclosure, revealed only for RANGE, announced politely. -->
|
||||
<div aria-live="polite" data-testid="end-date-region">
|
||||
{#if showEndDate}
|
||||
<div data-testid={endDateInnerTestId}>
|
||||
<label for="{dateInputName}End" class="mb-1 block text-sm font-medium text-ink-2">
|
||||
{m.form_label_date_end()}
|
||||
</label>
|
||||
<input
|
||||
id="{dateInputName}End"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
value={endDisplay}
|
||||
oninput={handleEndDateInput}
|
||||
placeholder={m.form_placeholder_date()}
|
||||
maxlength="10"
|
||||
aria-invalid={endBeforeStart ? 'true' : undefined}
|
||||
aria-describedby={endBeforeStart ? `${dateInputName}-end-error` : undefined}
|
||||
class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm
|
||||
{endBeforeStart
|
||||
? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500'
|
||||
: 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
|
||||
/>
|
||||
{#if endBeforeStart}
|
||||
<!-- Non-colour cue (WCAG 1.4.1): warning glyph + text, not red alone. -->
|
||||
<p id="{dateInputName}-end-error" class="mt-1 text-xs text-danger">
|
||||
<span aria-hidden="true">⚠ </span>{m.error_invalid_date_range()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Off-RANGE submits an empty string so a stale end-date never persists; the
|
||||
form action converts '' → null before sending the request body. -->
|
||||
<input type="hidden" name={endDateInputName} value={showEndDate ? endDateIso : ''} />
|
||||
@@ -1,38 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* A minimal fixed-series bar sparkline: one bar per value, heights scaled to the
|
||||
* largest value. Presentational only — callers supply the already-bucketed
|
||||
* counts. Used by the timeline density strip; reusable by the document chart.
|
||||
*/
|
||||
let {
|
||||
values,
|
||||
label,
|
||||
class: className = ''
|
||||
}: { values: number[]; label?: string; class?: string } = $props();
|
||||
|
||||
const max = $derived(Math.max(1, ...values));
|
||||
|
||||
// Empty buckets keep a faint floor so the series reads as a continuous axis
|
||||
// rather than disappearing to nothing.
|
||||
const MIN_HEIGHT_PCT = 4;
|
||||
|
||||
function heightPct(value: number): number {
|
||||
if (value <= 0) return MIN_HEIGHT_PCT;
|
||||
return Math.max(MIN_HEIGHT_PCT, (value / max) * 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex h-8 items-end gap-[1.5px] {className}"
|
||||
role="img"
|
||||
aria-label={label}
|
||||
aria-hidden={label ? undefined : 'true'}
|
||||
>
|
||||
{#each values as value, i (i)}
|
||||
<div
|
||||
data-testid="sparkline-bar"
|
||||
class="flex-1 rounded-[1px] bg-brand-mint"
|
||||
style="height: {heightPct(value)}%"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1,28 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import Sparkline from './Sparkline.svelte';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('Sparkline', () => {
|
||||
it('renders one bar per value', () => {
|
||||
render(Sparkline, { values: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] });
|
||||
const bars = document.querySelectorAll('[data-testid="sparkline-bar"]');
|
||||
expect(bars).toHaveLength(12);
|
||||
});
|
||||
|
||||
it('scales bar heights relative to the largest value', () => {
|
||||
render(Sparkline, { values: [5, 10, 0] });
|
||||
const bars = document.querySelectorAll<HTMLElement>('[data-testid="sparkline-bar"]');
|
||||
const h = (i: number) => parseFloat(bars[i].style.height);
|
||||
// 10 is the max → tallest; 5 is half of the max's height; 0 is the shortest.
|
||||
expect(h(1)).toBeGreaterThan(h(0));
|
||||
expect(h(0)).toBeGreaterThan(h(2));
|
||||
});
|
||||
|
||||
it('exposes an accessible label when provided', () => {
|
||||
render(Sparkline, { values: [1, 2, 3], label: 'Monatsdichte' });
|
||||
const img = document.querySelector('[role="img"]');
|
||||
expect(img?.getAttribute('aria-label')).toBe('Monatsdichte');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
/**
|
||||
* Server-side permission predicates derived from the authenticated user in `locals`.
|
||||
*
|
||||
@@ -14,17 +12,3 @@ type PermissionLocals = {
|
||||
export function hasWriteAll(locals: PermissionLocals): boolean {
|
||||
return locals.user?.groups?.some((group) => group.permissions.includes('WRITE_ALL')) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws a 403 unless the user holds WRITE_ALL. Anonymous users are rejected too
|
||||
* — `hasWriteAll` returns false for a null user, so a single check covers both
|
||||
* the unauthenticated and the under-privileged case. Server-side gate; the
|
||||
* frontend canWrite flag only hides entry-point buttons.
|
||||
*
|
||||
* Other WRITE_ALL-gated author loads (e.g. `documents/[id]/edit`) still inline
|
||||
* `if (!hasWriteAll(locals)) throw error(403)` — they can adopt this helper so
|
||||
* the guard doesn't quietly diverge across routes.
|
||||
*/
|
||||
export function requireWriteAll(locals: PermissionLocals): void {
|
||||
if (!hasWriteAll(locals)) throw error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
monthBoundaryFrom,
|
||||
monthBoundaryTo,
|
||||
buildMonthSequence,
|
||||
fillDensityGaps,
|
||||
aggregateToYears,
|
||||
selectionBoundaryFrom,
|
||||
selectionBoundaryTo,
|
||||
clipBucketsToRange,
|
||||
tickIndicesFor,
|
||||
formatTickLabel
|
||||
} from './monthBuckets';
|
||||
|
||||
describe('monthBoundaryFrom', () => {
|
||||
it('returns the first day of the given month', () => {
|
||||
expect(monthBoundaryFrom('1915-08')).toBe('1915-08-01');
|
||||
});
|
||||
|
||||
it('handles January', () => {
|
||||
expect(monthBoundaryFrom('1920-01')).toBe('1920-01-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('monthBoundaryTo', () => {
|
||||
it('returns the last day of a 31-day month', () => {
|
||||
expect(monthBoundaryTo('1915-08')).toBe('1915-08-31');
|
||||
});
|
||||
|
||||
it('returns the last day of a 30-day month', () => {
|
||||
expect(monthBoundaryTo('1915-04')).toBe('1915-04-30');
|
||||
});
|
||||
|
||||
it('returns 28 for February in a non-leap year', () => {
|
||||
expect(monthBoundaryTo('1915-02')).toBe('1915-02-28');
|
||||
});
|
||||
|
||||
it('returns 29 for February in a leap year', () => {
|
||||
expect(monthBoundaryTo('1916-02')).toBe('1916-02-29');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMonthSequence', () => {
|
||||
it('returns a single month when min and max are in the same month', () => {
|
||||
expect(buildMonthSequence('1915-08-03', '1915-08-22')).toEqual(['1915-08']);
|
||||
});
|
||||
|
||||
it('returns months from minDate through maxDate inclusive', () => {
|
||||
expect(buildMonthSequence('1915-08-03', '1915-11-15')).toEqual([
|
||||
'1915-08',
|
||||
'1915-09',
|
||||
'1915-10',
|
||||
'1915-11'
|
||||
]);
|
||||
});
|
||||
|
||||
it('crosses year boundaries correctly', () => {
|
||||
expect(buildMonthSequence('1915-11-30', '1916-02-01')).toEqual([
|
||||
'1915-11',
|
||||
'1915-12',
|
||||
'1916-01',
|
||||
'1916-02'
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array when minDate or maxDate is null', () => {
|
||||
expect(buildMonthSequence(null, '1915-08-01')).toEqual([]);
|
||||
expect(buildMonthSequence('1915-08-01', null)).toEqual([]);
|
||||
expect(buildMonthSequence(null, null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fillDensityGaps', () => {
|
||||
it('returns empty array when minDate or maxDate is null', () => {
|
||||
expect(fillDensityGaps([], null, null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves existing buckets and adds zero-count buckets for missing months', () => {
|
||||
const buckets = [
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-11', count: 2 }
|
||||
];
|
||||
|
||||
const result = fillDensityGaps(buckets, '1915-08-03', '1915-11-30');
|
||||
|
||||
expect(result).toEqual([
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-09', count: 0 },
|
||||
{ month: '1915-10', count: 0 },
|
||||
{ month: '1915-11', count: 2 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns all-zero sequence when buckets array is empty', () => {
|
||||
const result = fillDensityGaps([], '1915-08-03', '1915-10-15');
|
||||
|
||||
expect(result).toEqual([
|
||||
{ month: '1915-08', count: 0 },
|
||||
{ month: '1915-09', count: 0 },
|
||||
{ month: '1915-10', count: 0 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps results sorted chronologically even when buckets arrive out of order', () => {
|
||||
const buckets = [
|
||||
{ month: '1915-10', count: 3 },
|
||||
{ month: '1915-08', count: 1 }
|
||||
];
|
||||
|
||||
const result = fillDensityGaps(buckets, '1915-08-01', '1915-10-31');
|
||||
|
||||
expect(result.map((b) => b.month)).toEqual(['1915-08', '1915-09', '1915-10']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateToYears', () => {
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(aggregateToYears([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('sums counts within the same year', () => {
|
||||
const result = aggregateToYears([
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 8 }
|
||||
]);
|
||||
expect(result).toEqual([{ month: '1915', count: 15 }]);
|
||||
});
|
||||
|
||||
it('produces one bucket per distinct year, sorted chronologically', () => {
|
||||
const result = aggregateToYears([
|
||||
{ month: '1916-01', count: 3 },
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1916-04', count: 7 },
|
||||
{ month: '1914-12', count: 1 }
|
||||
]);
|
||||
expect(result).toEqual([
|
||||
{ month: '1914', count: 1 },
|
||||
{ month: '1915', count: 5 },
|
||||
{ month: '1916', count: 10 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clipBucketsToRange', () => {
|
||||
const buckets = [
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 8 },
|
||||
{ month: '1915-11', count: 3 }
|
||||
];
|
||||
|
||||
it('returns the original buckets when range bounds are null', () => {
|
||||
expect(clipBucketsToRange(buckets, null, null)).toBe(buckets);
|
||||
});
|
||||
|
||||
it('keeps only buckets whose month falls within the range', () => {
|
||||
expect(clipBucketsToRange(buckets, '1915-09-01', '1915-10-31')).toEqual([
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 8 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty array when the range excludes everything', () => {
|
||||
expect(clipBucketsToRange(buckets, '1916-01-01', '1916-12-31')).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats partial dates correctly when bounds cross month boundaries', () => {
|
||||
expect(clipBucketsToRange(buckets, '1915-09-15', '1915-10-15')).toEqual([
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 8 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectionBoundaryFrom / To', () => {
|
||||
it('handles month labels (YYYY-MM)', () => {
|
||||
expect(selectionBoundaryFrom('1915-08')).toBe('1915-08-01');
|
||||
expect(selectionBoundaryTo('1915-08')).toBe('1915-08-31');
|
||||
});
|
||||
|
||||
it('handles year labels (YYYY)', () => {
|
||||
expect(selectionBoundaryFrom('1915')).toBe('1915-01-01');
|
||||
expect(selectionBoundaryTo('1915')).toBe('1915-12-31');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tickIndicesFor', () => {
|
||||
it('returns no indices for an empty bucket list', () => {
|
||||
expect(tickIndicesFor([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('picks years divisible by 25 when the year span exceeds 120', () => {
|
||||
const buckets = Array.from({ length: 150 }, (_, i) => ({
|
||||
month: String(1875 + i),
|
||||
count: 1
|
||||
}));
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
const labels = ticks.map((i) => buckets[i].month);
|
||||
expect(labels).toEqual(['1875', '1900', '1925', '1950', '1975', '2000']);
|
||||
});
|
||||
|
||||
it('picks years divisible by 10 for medium ranges (~50 years)', () => {
|
||||
const buckets = Array.from({ length: 50 }, (_, i) => ({
|
||||
month: String(1900 + i),
|
||||
count: 1
|
||||
}));
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
const labels = ticks.map((i) => buckets[i].month);
|
||||
expect(labels).toEqual(['1900', '1910', '1920', '1930', '1940']);
|
||||
});
|
||||
|
||||
it('picks January boundaries for long month ranges', () => {
|
||||
const buckets = [
|
||||
{ month: '1914-08', count: 1 },
|
||||
{ month: '1914-09', count: 1 },
|
||||
{ month: '1914-10', count: 1 },
|
||||
{ month: '1914-11', count: 1 },
|
||||
{ month: '1914-12', count: 1 },
|
||||
{ month: '1915-01', count: 1 },
|
||||
{ month: '1915-02', count: 1 },
|
||||
{ month: '1915-03', count: 1 },
|
||||
{ month: '1915-04', count: 1 },
|
||||
{ month: '1915-05', count: 1 },
|
||||
{ month: '1915-06', count: 1 },
|
||||
{ month: '1915-07', count: 1 },
|
||||
{ month: '1915-08', count: 1 },
|
||||
{ month: '1915-09', count: 1 },
|
||||
{ month: '1915-10', count: 1 },
|
||||
{ month: '1915-11', count: 1 },
|
||||
{ month: '1915-12', count: 1 },
|
||||
{ month: '1916-01', count: 1 },
|
||||
{ month: '1916-02', count: 1 }
|
||||
];
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
expect(ticks.map((i) => buckets[i].month)).toEqual(['1915-01', '1916-01']);
|
||||
});
|
||||
|
||||
it('falls back to evenly spaced ticks for short month ranges (12 months)', () => {
|
||||
const buckets = Array.from({ length: 12 }, (_, i) => ({
|
||||
month: `1905-${String(i + 1).padStart(2, '0')}`,
|
||||
count: 1
|
||||
}));
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
expect(ticks.length).toBeGreaterThanOrEqual(5);
|
||||
expect(ticks.length).toBeLessThanOrEqual(7);
|
||||
expect(ticks[0]).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTickLabel', () => {
|
||||
it('returns the year string unchanged for year labels', () => {
|
||||
expect(formatTickLabel('1905', 'en-US')).toBe('1905');
|
||||
});
|
||||
|
||||
it('formats month labels with the year by default', () => {
|
||||
const result = formatTickLabel('1905-06', 'en-US');
|
||||
expect(result).toMatch(/Jun/);
|
||||
expect(result).toMatch(/1905/);
|
||||
});
|
||||
|
||||
it('omits the year when omitYear is true', () => {
|
||||
const result = formatTickLabel('1905-06', 'en-US', true);
|
||||
expect(result).toMatch(/Jun/);
|
||||
expect(result).not.toMatch(/1905/);
|
||||
});
|
||||
});
|
||||
@@ -1,163 +0,0 @@
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
/**
|
||||
* Pure month-bucket math shared by the document density chart (`lib/document/`)
|
||||
* and the global timeline strip (`lib/timeline/`). Reuses the generated
|
||||
* `MonthBucket` schema type so both surfaces stay coupled to the backend shape.
|
||||
* No I/O, no DOM — relocated here so `lib/timeline/` never imports `lib/document/`.
|
||||
*/
|
||||
export type MonthBucket = components['schemas']['MonthBucket'];
|
||||
|
||||
export function monthBoundaryFrom(yearMonth: string): string {
|
||||
return `${yearMonth}-01`;
|
||||
}
|
||||
|
||||
export function monthBoundaryTo(yearMonth: string): string {
|
||||
const [year, month] = yearMonth.split('-').map(Number);
|
||||
// Day 0 of `month + 1` rolls back to the last day of `month` — so passing
|
||||
// `month` (1-indexed) into `Date.UTC(year, month, 0)` lands on the last day
|
||||
// of that month. Handles 28/29/30/31 and leap years without a lookup table.
|
||||
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
return `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function buildMonthSequence(minDate: string | null, maxDate: string | null): string[] {
|
||||
if (!minDate || !maxDate) return [];
|
||||
|
||||
const [minY, minM] = minDate.split('-').map(Number);
|
||||
const [maxY, maxM] = maxDate.split('-').map(Number);
|
||||
|
||||
const sequence: string[] = [];
|
||||
let year = minY;
|
||||
let month = minM;
|
||||
|
||||
while (year < maxY || (year === maxY && month <= maxM)) {
|
||||
sequence.push(`${year}-${String(month).padStart(2, '0')}`);
|
||||
month += 1;
|
||||
if (month > 12) {
|
||||
month = 1;
|
||||
year += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return sequence;
|
||||
}
|
||||
|
||||
export function fillDensityGaps(
|
||||
buckets: MonthBucket[],
|
||||
minDate: string | null,
|
||||
maxDate: string | null
|
||||
): MonthBucket[] {
|
||||
const sequence = buildMonthSequence(minDate, maxDate);
|
||||
if (sequence.length === 0) return [];
|
||||
|
||||
const counts = new Map(buckets.map((b) => [b.month, b.count]));
|
||||
return sequence.map((month) => ({ month, count: counts.get(month) ?? 0 }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns only the month buckets whose YYYY-MM falls inside the provided
|
||||
* `[fromInclusive, toInclusive]` ISO date range. When either bound is null the
|
||||
* input array is returned unchanged. Used by the timeline's zoom-in tool to
|
||||
* narrow the visible bars without refetching data.
|
||||
*
|
||||
* @internal Sole call site is `TimelineDensityFilter.svelte`. Exported so the
|
||||
* unit suite (`monthBuckets.spec.ts`) can pin the boundary semantics directly.
|
||||
*/
|
||||
export function clipBucketsToRange(
|
||||
buckets: MonthBucket[],
|
||||
fromInclusive: string | null,
|
||||
toInclusive: string | null
|
||||
): MonthBucket[] {
|
||||
if (!fromInclusive || !toInclusive) return buckets;
|
||||
const fromMonth = fromInclusive.slice(0, 7);
|
||||
const toMonth = toInclusive.slice(0, 7);
|
||||
return buckets.filter((b) => b.month >= fromMonth && b.month <= toMonth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates month-granular buckets into one entry per year. Month strings are
|
||||
* truncated to "YYYY" and counts are summed. Used when the date span is too
|
||||
* long for month-granular bars to render at a clickable size.
|
||||
*/
|
||||
export function aggregateToYears(buckets: MonthBucket[]): MonthBucket[] {
|
||||
const totals = new Map<string, number>();
|
||||
for (const b of buckets) {
|
||||
const year = b.month.slice(0, 4);
|
||||
totals.set(year, (totals.get(year) ?? 0) + b.count);
|
||||
}
|
||||
return Array.from(totals.entries())
|
||||
.map(([year, count]) => ({ month: year, count }))
|
||||
.sort((a, b) => a.month.localeCompare(b.month));
|
||||
}
|
||||
|
||||
/**
|
||||
* Boundary helpers for selection. Accept either "YYYY-MM" (month) or "YYYY"
|
||||
* (year) and return the matching LocalDate string.
|
||||
*/
|
||||
export function selectionBoundaryFrom(label: string): string {
|
||||
return label.length === 4 ? `${label}-01-01` : `${label}-01`;
|
||||
}
|
||||
|
||||
export function selectionBoundaryTo(label: string): string {
|
||||
if (label.length === 4) return `${label}-12-31`;
|
||||
return monthBoundaryTo(label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks bucket indices that should get an X-axis tick label. The strategy adapts
|
||||
* to whether bars are years or months and how many are visible:
|
||||
* - Year bars: pick years divisible by a step that scales with range length
|
||||
* (every 25 yrs for >120 bars, every 20 / 10 / 5 / 1 below).
|
||||
* - Month bars: prefer January boundaries (year breaks). For ≤18 bars (e.g.
|
||||
* one year zoomed in to months), fall back to evenly spaced ticks so we
|
||||
* show ~6 labels even when no January boundary exists.
|
||||
*/
|
||||
export function tickIndicesFor(filled: MonthBucket[]): number[] {
|
||||
if (filled.length === 0) return [];
|
||||
const isYearMode = filled[0].month.length === 4;
|
||||
const indices: number[] = [];
|
||||
|
||||
if (isYearMode) {
|
||||
const years = filled.length;
|
||||
const step =
|
||||
years > 120 ? 25 : years > 60 ? 20 : years > 30 ? 10 : years > 12 ? 5 : years > 6 ? 2 : 1;
|
||||
for (let i = 0; i < filled.length; i++) {
|
||||
const year = parseInt(filled[i].month, 10);
|
||||
if (year % step === 0) indices.push(i);
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
|
||||
if (filled.length <= 18) {
|
||||
const step = Math.max(1, Math.round(filled.length / 6));
|
||||
for (let i = 0; i < filled.length; i += step) indices.push(i);
|
||||
return indices;
|
||||
}
|
||||
|
||||
// Long month range — pick January boundaries (year breaks).
|
||||
for (let i = 0; i < filled.length; i++) {
|
||||
if (filled[i].month.endsWith('-01')) indices.push(i);
|
||||
}
|
||||
// Fallback if there's no January in the visible range (rare): even spacing.
|
||||
if (indices.length === 0) {
|
||||
const step = Math.max(1, Math.round(filled.length / 6));
|
||||
for (let i = 0; i < filled.length; i += step) indices.push(i);
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a bucket month label ("YYYY" or "YYYY-MM") for the X-axis. When
|
||||
* `omitYear` is true the year is dropped so a 12-month zoomed view reads as
|
||||
* "Jan", "Feb", … without repetition.
|
||||
*/
|
||||
export function formatTickLabel(label: string, locale?: string, omitYear = false): string {
|
||||
if (label.length === 4) return label;
|
||||
const [yearStr, monthStr] = label.split('-');
|
||||
const date = new Date(parseInt(yearStr, 10), parseInt(monthStr, 10) - 1, 1);
|
||||
const opts: Intl.DateTimeFormatOptions = omitYear
|
||||
? { month: 'short' }
|
||||
: { month: 'short', year: 'numeric' };
|
||||
return new Intl.DateTimeFormat(locale, opts).format(date);
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import type { DatePrecision } from '$lib/shared/utils/documentDate';
|
||||
import { toPersonOption, type PersonOption } from '$lib/person/personOption';
|
||||
import { type DocumentOption } from '$lib/document/documentTypeahead';
|
||||
import { getConfirmService } from '$lib/shared/services/confirm.svelte.js';
|
||||
import PersonMultiSelect from '$lib/person/PersonMultiSelect.svelte';
|
||||
import DocumentMultiSelect from '$lib/document/DocumentMultiSelect.svelte';
|
||||
import DatePrecisionField from '$lib/shared/primitives/DatePrecisionField.svelte';
|
||||
import EventTypeSelect from '$lib/timeline/EventTypeSelect.svelte';
|
||||
import BackButton from '$lib/shared/primitives/BackButton.svelte';
|
||||
|
||||
type TimelineEventView = components['schemas']['TimelineEventView'];
|
||||
|
||||
/**
|
||||
* Curator create/edit form for a timeline event. One component, two routes:
|
||||
* `/new` renders it empty, `/[id]/edit` renders it seeded with `event`. The
|
||||
* markup is never forked. All data flows through the route's +page.server.ts
|
||||
* load + form action (SSR) — there is no client fetch('/api/...') here.
|
||||
*/
|
||||
interface FormResult {
|
||||
error?: string;
|
||||
titleError?: string;
|
||||
dateError?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
type?: string;
|
||||
personIds?: string[];
|
||||
documentIds?: string[];
|
||||
// Rehydrated chip data (id + label) so the pickers re-render after a fail(400)
|
||||
// even on a no-JS full reload — bare ids alone can't rebuild a chip (REQ-010).
|
||||
persons?: PersonOption[];
|
||||
documents?: DocumentOption[];
|
||||
}
|
||||
|
||||
let {
|
||||
event = undefined,
|
||||
initialPersons = [],
|
||||
initialDocuments = [],
|
||||
originPersonId = '',
|
||||
form = null
|
||||
}: {
|
||||
event?: TimelineEventView;
|
||||
initialPersons?: PersonOption[];
|
||||
initialDocuments?: DocumentOption[];
|
||||
originPersonId?: string;
|
||||
form?: FormResult | null;
|
||||
} = $props();
|
||||
|
||||
// Initial-state snapshot from incoming props, preferring a preserved fail payload
|
||||
// over the seeded `event`. This component is intentionally single-shot: props are
|
||||
// snapshotted into $state once, so a parent re-render with a different `event`
|
||||
// won't update the form — the two dedicated routes always remount, which is fine.
|
||||
let title = $state(form?.title ?? event?.title ?? '');
|
||||
let description = $state(form?.description ?? event?.description ?? '');
|
||||
let type = $state<string>(form?.type ?? event?.type ?? 'PERSONAL');
|
||||
let dateIso = $state(event?.eventDate ?? '');
|
||||
let precision = $state<DatePrecision>((event?.precision as DatePrecision) ?? 'DAY');
|
||||
let endDateIso = $state(event?.eventDateEnd ?? '');
|
||||
|
||||
// On a fail(400) the server returns rehydrated chip data (form.persons/documents)
|
||||
// so the pickers survive the round-trip — even without JS — ahead of the seeded
|
||||
// `event` or the prefill initials (REQ-010 / Decision 6).
|
||||
let selectedPersons = $state<PersonOption[]>(
|
||||
form?.persons ?? (event?.persons ? event.persons.map(toPersonOption) : initialPersons)
|
||||
);
|
||||
let selectedDocuments = $state<DocumentOption[]>(
|
||||
form?.documents ??
|
||||
(event?.documents
|
||||
? event.documents.map((d) => ({
|
||||
// Graceful degradation: DocumentRef has no precision fields. formatDocumentOption
|
||||
// defaults a missing precision to DAY, so the chip shows the full documentDate.
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
documentDate: d.documentDate
|
||||
}))
|
||||
: initialDocuments)
|
||||
);
|
||||
|
||||
const isEdit = $derived(event !== undefined);
|
||||
|
||||
let titleTouched = $state(false);
|
||||
let submitting = $state(false);
|
||||
let dirty = $state(false);
|
||||
|
||||
const titleEmpty = $derived(title.trim().length === 0);
|
||||
// Client-side title error fires instantly on a save attempt; the server's
|
||||
// titleError is the simultaneous-multi-field source on a real round-trip.
|
||||
const titleError = $derived(
|
||||
form?.titleError ?? (titleTouched && titleEmpty ? m.event_editor_title_required() : '')
|
||||
);
|
||||
const dateError = $derived(form?.dateError ?? '');
|
||||
|
||||
beforeNavigate(({ cancel }) => {
|
||||
if (dirty && !submitting) {
|
||||
const ok = window.confirm(m.event_editor_unsaved_changes());
|
||||
if (!ok) cancel();
|
||||
}
|
||||
});
|
||||
|
||||
// Every editable control routes its change through markDirty so the
|
||||
// beforeNavigate guard catches edits to the date/precision/end-date and the
|
||||
// pickers too — not just title/type/description (their onchange callbacks call
|
||||
// this). No $effect: marking dirty from the actual edit events avoids a
|
||||
// snapshot-vs-effect mount-timing trap.
|
||||
function markDirty() {
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
// Guards a submit with a blank title client-side. The server re-validates and
|
||||
// owns the authoritative fail(400) with per-field flags.
|
||||
function handleSubmit(e: SubmitEvent) {
|
||||
titleTouched = true;
|
||||
if (titleEmpty) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
const { confirm } = getConfirmService();
|
||||
const ok = await confirm({
|
||||
title: m.event_editor_delete_confirm_title(),
|
||||
body: m.event_editor_delete_confirm_body(),
|
||||
destructive: true,
|
||||
confirmLabel: m.event_editor_delete()
|
||||
});
|
||||
if (ok) (e.target as HTMLFormElement).requestSubmit();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-5xl px-4 py-8">
|
||||
<div class="mb-6">
|
||||
<BackButton />
|
||||
</div>
|
||||
|
||||
<h1 class="mb-6 font-serif text-3xl font-bold text-ink">
|
||||
{isEdit ? m.event_editor_edit_title() : m.event_editor_new_title()}
|
||||
</h1>
|
||||
|
||||
{#if form?.error}
|
||||
<p
|
||||
class="mb-4 rounded-sm border border-danger/40 bg-danger/10 px-4 py-3 text-sm text-danger"
|
||||
role="alert"
|
||||
>
|
||||
{form.error}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/save"
|
||||
onsubmit={handleSubmit}
|
||||
use:enhance={() => {
|
||||
submitting = true;
|
||||
return async ({ update }) => {
|
||||
submitting = false;
|
||||
dirty = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="originPersonId" value={originPersonId} />
|
||||
{#if event}
|
||||
<!-- Optimistic-lock version travels back to the PUT so #3 can reject a
|
||||
stale edit with 409. -->
|
||||
<input type="hidden" name="version" value={event.version} />
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]">
|
||||
<!-- Main column -->
|
||||
<div class="flex flex-col gap-6">
|
||||
<!-- Titel + Typ + Datum -->
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.event_editor_section_when()}
|
||||
</h2>
|
||||
|
||||
<div class="mb-5">
|
||||
<label for="event-title" class="mb-1 block text-sm font-medium text-ink-2">
|
||||
{m.event_editor_title_label()}*
|
||||
</label>
|
||||
<input
|
||||
id="event-title"
|
||||
name="title"
|
||||
type="text"
|
||||
bind:value={title}
|
||||
oninput={markDirty}
|
||||
onblur={() => (titleTouched = true)}
|
||||
maxlength="255"
|
||||
placeholder={m.event_editor_title_placeholder()}
|
||||
aria-required="true"
|
||||
aria-invalid={titleError ? 'true' : undefined}
|
||||
aria-describedby={titleError ? 'event-title-error' : undefined}
|
||||
class="block min-h-[48px] w-full rounded border border-line px-3 py-3 text-base shadow-sm
|
||||
{titleError
|
||||
? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500'
|
||||
: 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
|
||||
/>
|
||||
{#if titleError}
|
||||
<p id="event-title-error" class="mt-1 text-sm text-danger" role="alert">
|
||||
<span aria-hidden="true">⚠ </span>{titleError}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mb-5">
|
||||
<span class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.event_editor_type_label()}</span
|
||||
>
|
||||
<EventTypeSelect value={type} name="type" onchange={(t) => {
|
||||
type = t;
|
||||
markDirty();
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||
<DatePrecisionField
|
||||
bind:dateIso={dateIso}
|
||||
bind:precision={precision}
|
||||
bind:endDateIso={endDateIso}
|
||||
dateInputName="eventDate"
|
||||
endDateInputName="eventDateEnd"
|
||||
precisionInputName="precision"
|
||||
dateLabel={m.form_label_date()}
|
||||
dateError={dateError}
|
||||
onchange={markDirty}
|
||||
dateTestId="event-date"
|
||||
precisionTestId="event-precision"
|
||||
endDateInnerTestId="event-end-date"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Beschreibung -->
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.event_editor_section_description()}
|
||||
</h2>
|
||||
<label for="event-description" class="sr-only">{m.event_editor_description_label()}</label
|
||||
>
|
||||
<textarea
|
||||
id="event-description"
|
||||
name="description"
|
||||
bind:value={description}
|
||||
oninput={markDirty}
|
||||
rows="4"
|
||||
placeholder={m.event_editor_description_placeholder()}
|
||||
class="block w-full rounded border border-line px-3 py-3 text-base shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="flex flex-col gap-6">
|
||||
<!-- Beteiligte Personen -->
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.event_editor_section_persons()}
|
||||
</h2>
|
||||
<label for="event-persons-input" class="mb-1 block text-sm font-medium text-ink-2">
|
||||
{m.event_editor_persons_label()}
|
||||
</label>
|
||||
<PersonMultiSelect
|
||||
bind:selectedPersons={selectedPersons}
|
||||
inputId="event-persons-input"
|
||||
hiddenInputName="personIds"
|
||||
emptyLabel={m.event_editor_persons_empty()}
|
||||
onchange={markDirty}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Verknüpfte Briefe -->
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.event_editor_section_documents()}
|
||||
</h2>
|
||||
<label for="event-documents-input" class="mb-1 block text-sm font-medium text-ink-2">
|
||||
{m.event_editor_documents_label()}
|
||||
</label>
|
||||
<DocumentMultiSelect
|
||||
bind:selectedDocuments={selectedDocuments}
|
||||
inputId="event-documents-input"
|
||||
hiddenInputName="documentIds"
|
||||
emptyLabel={m.event_editor_documents_empty()}
|
||||
onchange={markDirty}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save bar -->
|
||||
<div
|
||||
class="sticky bottom-0 z-10 -mx-4 mt-6 flex flex-col gap-3 border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<p class="font-sans text-xs text-ink-3">{m.event_editor_save_hint()}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{m.event_editor_save()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#if isEdit}
|
||||
<!-- Delete lives in its own form so it posts to the dedicated ?/delete action.
|
||||
getConfirmService() is read lazily inside the handler so the component
|
||||
mounts cleanly outside a layout (tests) where no confirm context exists. -->
|
||||
<form method="POST" action="?/delete" onsubmit={confirmDelete} use:enhance class="mt-4">
|
||||
<input type="hidden" name="originPersonId" value={originPersonId} />
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex h-11 items-center rounded border border-danger/40 px-4 font-sans text-sm font-medium text-danger hover:bg-danger/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{m.event_editor_delete()}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,113 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import EventForm from './EventForm.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
type TimelineEventView = components['schemas']['TimelineEventView'];
|
||||
|
||||
/**
|
||||
* Minimal TimelineEventView shape used to seed the edit form. Mirrors
|
||||
* components['schemas']['TimelineEventView'] — all server-populated fields.
|
||||
*/
|
||||
function makeEvent(overrides: Partial<TimelineEventView> = {}): TimelineEventView {
|
||||
return {
|
||||
id: 'e1',
|
||||
title: 'Umzug nach Berlin',
|
||||
type: 'PERSONAL',
|
||||
eventDate: '1925-04-01',
|
||||
precision: 'DAY',
|
||||
version: 0,
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedBy: 'u1',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
persons: [],
|
||||
documents: [],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('EventForm — date precision RANGE reveal (headline AC, REQ-008/009)', () => {
|
||||
it('reveals the end-date field when precision is RANGE', async () => {
|
||||
render(EventForm, { event: makeEvent({ precision: 'RANGE', eventDateEnd: '1925-05-01' }) });
|
||||
await expect.element(page.getByLabelText('Enddatum')).toBeVisible();
|
||||
});
|
||||
|
||||
it('hides the end-date field when precision is YEAR', async () => {
|
||||
render(EventForm, { event: makeEvent({ precision: 'YEAR' }) });
|
||||
await expect.element(page.getByTestId('end-date-region')).toBeInTheDocument();
|
||||
await expect.element(page.getByLabelText('Enddatum')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EventForm — picker preselect (REQ-014)', () => {
|
||||
it('preselects a person when initialPersons is provided', async () => {
|
||||
render(EventForm, {
|
||||
initialPersons: [{ id: 'p1', displayName: 'Anna Müller' }]
|
||||
});
|
||||
await expect.element(page.getByText('Anna Müller')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EventForm — required-field error (REQ-010)', () => {
|
||||
it('shows a required-field error when title is blank and save is attempted', async () => {
|
||||
render(EventForm, {});
|
||||
await page.getByRole('button', { name: 'Speichern' }).click();
|
||||
await expect.element(page.getByText('Bitte einen Titel eingeben.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('rehydrates the pickers from the fail payload (Decision 6)', async () => {
|
||||
render(EventForm, {
|
||||
form: {
|
||||
titleError: 'Bitte einen Titel eingeben.',
|
||||
title: '',
|
||||
persons: [{ id: 'p1', displayName: 'Anna Müller' }],
|
||||
documents: [{ id: 'd1', title: 'Brief A', documentDate: '1925-04-01' }]
|
||||
}
|
||||
});
|
||||
await expect.element(page.getByText('Anna Müller')).toBeInTheDocument();
|
||||
await expect.element(page.getByText(/Brief A/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EventForm — server date error wired per-field (REQ-011)', () => {
|
||||
it('marks the date field aria-invalid and shows the message on a server date error', async () => {
|
||||
render(EventForm, { form: { dateError: 'Bitte ein Datum eingeben.' } });
|
||||
await expect.element(page.getByText('Bitte ein Datum eingeben.')).toBeInTheDocument();
|
||||
const dateInput = document.querySelector('#eventDate') as HTMLInputElement;
|
||||
expect(dateInput.getAttribute('aria-invalid')).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EventForm — submitting state (named AC, Decision 8)', () => {
|
||||
it('disables the submit button while submitting', async () => {
|
||||
// A never-resolving fetch keeps use:enhance in flight so the disabled
|
||||
// transition (the double-submit guard) is observable rather than racing the
|
||||
// reset in the result callback.
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => new Promise(() => {}))
|
||||
);
|
||||
render(EventForm, { event: makeEvent() });
|
||||
const btn = page.getByRole('button', { name: 'Speichern' });
|
||||
await expect.element(btn).not.toBeDisabled();
|
||||
await btn.click();
|
||||
await expect.element(btn).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EventForm — server error surfaced inline (REQ-007/013)', () => {
|
||||
it('renders the mapped error from the form prop', async () => {
|
||||
render(EventForm, {
|
||||
event: makeEvent(),
|
||||
form: { error: 'Etwas ist schiefgelaufen.' }
|
||||
});
|
||||
await expect.element(page.getByText('Etwas ist schiefgelaufen.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { getAccentConfig } from './eventCardConfig';
|
||||
import { timelineDateLabel } from './dateLabel';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
||||
|
||||
/**
|
||||
* Centered axis pill for a derived life-event or a curated PERSONAL event
|
||||
* (REQ-007/008). The glyph is wrapped aria-hidden with an sr-only label sibling
|
||||
* (REQ-018). An edit affordance shows only for a curated event with an eventId
|
||||
* (never derived, never null — REQ-008).
|
||||
*/
|
||||
let { entry }: { entry: TimelineEntryDTO } = $props();
|
||||
|
||||
const config = $derived(getAccentConfig(entry));
|
||||
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
|
||||
const canEdit = $derived(!entry.derived && entry.eventId != null);
|
||||
</script>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<div
|
||||
class="inline-flex items-center gap-2 rounded-full bg-surface px-3 py-1 shadow-sm {config.accent ===
|
||||
'curated'
|
||||
? 'border-2 border-brand-mint'
|
||||
: 'border border-brand-navy'}"
|
||||
>
|
||||
<span
|
||||
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full {config.accent ===
|
||||
'curated'
|
||||
? 'bg-brand-mint text-brand-navy'
|
||||
: 'bg-brand-navy text-brand-mint'}"
|
||||
>
|
||||
<span aria-hidden="true">{config.glyph}</span>
|
||||
<span class="sr-only">{config.label}</span>
|
||||
</span>
|
||||
<span class="text-left">
|
||||
{#if entry.title}
|
||||
<span class="block font-serif text-sm font-bold whitespace-pre-line text-brand-navy"
|
||||
>{entry.title}</span
|
||||
>
|
||||
{/if}
|
||||
{#if dateLabel}
|
||||
<span class="block font-sans text-xs text-ink-3">{dateLabel}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if canEdit}
|
||||
<a
|
||||
data-testid="event-edit"
|
||||
href="/zeitstrahl/events/{entry.eventId}/edit"
|
||||
class="ml-1 rounded-sm px-1 font-sans text-xs text-ink-3 hover:text-brand-navy focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||
>
|
||||
<span aria-hidden="true">✎</span>
|
||||
<span class="sr-only">{m.btn_edit()}</span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,90 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import EventPill from './EventPill.svelte';
|
||||
import { makeEntry } from './test-factories';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const EVENT_ID = '33333333-3333-3333-3333-333333333333';
|
||||
|
||||
function derived(derivedType: 'BIRTH' | 'DEATH' | 'MARRIAGE', title: string) {
|
||||
return makeEntry({
|
||||
kind: 'EVENT',
|
||||
derived: true,
|
||||
derivedType,
|
||||
title,
|
||||
senderName: '',
|
||||
receiverName: '',
|
||||
precision: 'YEAR',
|
||||
eventDate: '1914-01-01',
|
||||
documentId: undefined
|
||||
});
|
||||
}
|
||||
|
||||
describe('EventPill', () => {
|
||||
it('renders a derived marriage as ⚭ + "Heirat" + title (REQ-007)', () => {
|
||||
render(EventPill, { entry: derived('MARRIAGE', 'Heirat: Karl & Elfriede') });
|
||||
expect(document.body.textContent).toContain('⚭');
|
||||
expect(document.body.textContent).toContain('Heirat');
|
||||
expect(document.body.textContent).toContain('Heirat: Karl & Elfriede');
|
||||
});
|
||||
|
||||
it('renders a derived birth as * + "Geburt" (REQ-007)', () => {
|
||||
render(EventPill, { entry: derived('BIRTH', 'Geburt: Hans') });
|
||||
expect(document.body.textContent).toContain('*');
|
||||
expect(document.body.textContent).toContain('Geburt');
|
||||
});
|
||||
|
||||
it('renders a derived death as † + "Tod" (REQ-007)', () => {
|
||||
render(EventPill, { entry: derived('DEATH', 'Tod: Karl') });
|
||||
expect(document.body.textContent).toContain('†');
|
||||
expect(document.body.textContent).toContain('Tod');
|
||||
});
|
||||
|
||||
it('wraps the glyph aria-hidden with an sr-only label sibling (REQ-018)', () => {
|
||||
render(EventPill, { entry: derived('BIRTH', 'Geburt: Hans') });
|
||||
const hidden = document.querySelector('[aria-hidden="true"]');
|
||||
expect(hidden?.textContent).toBe('*');
|
||||
const srOnly = document.querySelector('.sr-only');
|
||||
expect(srOnly?.textContent).toBe('Geburt');
|
||||
});
|
||||
|
||||
it('shows an edit affordance for a curated PERSONAL event with an eventId (REQ-008)', () => {
|
||||
render(EventPill, {
|
||||
entry: makeEntry({
|
||||
kind: 'EVENT',
|
||||
derived: false,
|
||||
type: 'PERSONAL',
|
||||
eventId: EVENT_ID,
|
||||
title: 'Auswanderung',
|
||||
senderName: '',
|
||||
receiverName: '',
|
||||
documentId: undefined
|
||||
})
|
||||
});
|
||||
const edit = document.querySelector('[data-testid="event-edit"]') as HTMLAnchorElement | null;
|
||||
expect(edit).not.toBeNull();
|
||||
expect(edit?.getAttribute('href')).toContain(EVENT_ID);
|
||||
});
|
||||
|
||||
it('shows no edit affordance when eventId is null (REQ-008)', () => {
|
||||
render(EventPill, {
|
||||
entry: makeEntry({
|
||||
kind: 'EVENT',
|
||||
derived: false,
|
||||
type: 'PERSONAL',
|
||||
eventId: undefined,
|
||||
title: 'Auswanderung',
|
||||
senderName: '',
|
||||
receiverName: '',
|
||||
documentId: undefined
|
||||
})
|
||||
});
|
||||
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows no edit affordance for a derived event (REQ-008)', () => {
|
||||
render(EventPill, { entry: derived('MARRIAGE', 'Heirat') });
|
||||
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { radioGroupNav } from '$lib/shared/actions/radioGroupNav';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
type EventType = 'PERSONAL' | 'HISTORICAL';
|
||||
const TYPES: EventType[] = ['PERSONAL', 'HISTORICAL'];
|
||||
|
||||
let {
|
||||
value = 'PERSONAL',
|
||||
name = 'type',
|
||||
onchange
|
||||
}: { value?: string; name?: string; onchange?: (type: EventType) => void } = $props();
|
||||
|
||||
let selected = $state<EventType>(
|
||||
untrack(() => (TYPES.includes(value as EventType) ? (value as EventType) : 'PERSONAL'))
|
||||
);
|
||||
|
||||
let announcement = $state('');
|
||||
|
||||
const labels: Record<EventType, () => string> = {
|
||||
PERSONAL: m.event_type_PERSONAL,
|
||||
HISTORICAL: m.event_type_HISTORICAL
|
||||
};
|
||||
|
||||
// Decorative accents only — never the sole differentiator (text label is always
|
||||
// present). aria-hidden so AT announces the label, not the glyph.
|
||||
const icons: Record<EventType, string> = {
|
||||
PERSONAL: '👤',
|
||||
HISTORICAL: '🏛'
|
||||
};
|
||||
|
||||
function select(type: EventType) {
|
||||
selected = type;
|
||||
announcement = m.a11y_type_changed({ type: labels[type]() });
|
||||
onchange?.(type);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={m.event_editor_type_label()}
|
||||
class="grid grid-cols-2 gap-2"
|
||||
use:radioGroupNav={(v) => {
|
||||
if (TYPES.includes(v as EventType)) select(v as EventType);
|
||||
}}
|
||||
>
|
||||
{#each TYPES as type (type)}
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
value={type}
|
||||
aria-checked={selected === type}
|
||||
tabindex={selected === type ? 0 : -1}
|
||||
onclick={() => select(type)}
|
||||
class="flex min-h-[48px] cursor-pointer items-center gap-2 rounded-sm border px-3 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none {selected ===
|
||||
type
|
||||
? 'border-primary bg-primary text-primary-fg'
|
||||
: 'border-line bg-surface text-ink hover:border-primary/50'}"
|
||||
>
|
||||
<span class="text-lg leading-none" aria-hidden="true">{icons[type]}</span>
|
||||
<span>{labels[type]()}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<input type="hidden" name={name} value={selected} />
|
||||
|
||||
<div class="sr-only" aria-live="polite" aria-atomic="true">{announcement}</div>
|
||||
@@ -1,27 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import EventTypeSelect from './EventTypeSelect.svelte';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('EventTypeSelect — segmented PERSONAL/HISTORICAL radio', () => {
|
||||
it('renders exactly two radio options', async () => {
|
||||
render(EventTypeSelect, { value: 'PERSONAL' });
|
||||
const radios = document.querySelectorAll('[role="radio"]');
|
||||
expect(radios.length).toBe(2);
|
||||
});
|
||||
|
||||
it('marks the initial value as checked and seeds the hidden input', async () => {
|
||||
render(EventTypeSelect, { value: 'HISTORICAL', name: 'type' });
|
||||
const hidden = document.querySelector('input[type="hidden"][name="type"]') as HTMLInputElement;
|
||||
expect(hidden.value).toBe('HISTORICAL');
|
||||
});
|
||||
|
||||
it('selects HISTORICAL and updates the hidden input when clicked', async () => {
|
||||
render(EventTypeSelect, { value: 'PERSONAL', name: 'type' });
|
||||
await page.getByRole('radio', { name: 'Historisch' }).click();
|
||||
const hidden = document.querySelector('input[type="hidden"][name="type"]') as HTMLInputElement;
|
||||
expect(hidden.value).toBe('HISTORICAL');
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
/**
|
||||
* A folded run of fully-empty interior years (REQ-015), rendered as a thin
|
||||
* dashed span so the scroll stays oriented. Collapses to a single year when the
|
||||
* run has length 1.
|
||||
*/
|
||||
let { from, to }: { from: number; to: number } = $props();
|
||||
|
||||
const yearLabel = $derived(from === to ? `${from}` : `${from}–${to}`);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="mx-auto my-2 flex max-w-md items-center gap-2 rounded-full border border-dashed border-line bg-canvas px-4 py-1 font-sans text-xs text-ink-3"
|
||||
>
|
||||
<span class="h-px flex-1 bg-line"></span>
|
||||
<span><span class="font-serif text-ink-2">{yearLabel}</span> · {m.timeline_gap_empty()}</span>
|
||||
<span class="h-px flex-1 bg-line"></span>
|
||||
</div>
|
||||
@@ -1,20 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import GapSpan from './GapSpan.svelte';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('GapSpan', () => {
|
||||
it('renders a multi-year empty run as "{from}–{to} · keine Einträge" (REQ-015)', () => {
|
||||
render(GapSpan, { from: 1910, to: 1914 });
|
||||
expect(document.body.textContent).toContain('1910–1914');
|
||||
expect(document.body.textContent).toContain('keine Einträge');
|
||||
});
|
||||
|
||||
it('renders a single empty year as "{year} · keine Einträge" (REQ-015)', () => {
|
||||
render(GapSpan, { from: 1912, to: 1912 });
|
||||
expect(document.body.textContent).toContain('1912');
|
||||
expect(document.body.textContent).not.toContain('1912–1912');
|
||||
expect(document.body.textContent).toContain('keine Einträge');
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { timelineDateLabel } from './dateLabel';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
||||
|
||||
/**
|
||||
* A single archive letter on the timeline: sender → receiver, title, and a
|
||||
* precision-aware date chip, linking to the document. Names/titles are
|
||||
* OCR/import-derived — rendered via default `{...}` escaping with
|
||||
* `whitespace-pre-line` for line breaks (REQ-021); never the raw-HTML directive.
|
||||
*/
|
||||
let { entry }: { entry: TimelineEntryDTO } = $props();
|
||||
|
||||
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
|
||||
const sender = $derived(entry.senderName === '' ? m.timeline_unknown_person() : entry.senderName);
|
||||
const receiver = $derived(
|
||||
entry.receiverName === '' ? m.timeline_unknown_person() : entry.receiverName
|
||||
);
|
||||
</script>
|
||||
|
||||
<!-- Box layout inline (not just utility classes) so the 44px touch target holds
|
||||
even before the stylesheet loads — an <a> is inline by default and would
|
||||
ignore min-height otherwise. WCAG 2.5.5 (REQ-020). -->
|
||||
<a
|
||||
href="/documents/{entry.documentId}"
|
||||
style="display: flex; flex-direction: column; justify-content: center; min-height: 44px"
|
||||
class="rounded-sm border border-l-[3px] border-line border-l-brand-mint bg-surface px-3 py-2 shadow-sm transition-colors hover:border-brand-mint focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||
>
|
||||
{#if entry.title}
|
||||
<span class="font-serif text-sm font-bold break-words whitespace-pre-line text-ink"
|
||||
>{entry.title}</span
|
||||
>
|
||||
{/if}
|
||||
<span class="mt-0.5 font-sans text-xs break-words text-ink-3">
|
||||
<span class="font-serif whitespace-pre-line">{sender}</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
<span class="font-serif whitespace-pre-line">{receiver}</span>
|
||||
{#if dateLabel}
|
||||
<span data-testid="letter-date"> · {dateLabel}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</a>
|
||||
@@ -1,58 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import LetterCard from './LetterCard.svelte';
|
||||
import { timelineDateLabel } from './dateLabel';
|
||||
import { makeEntry } from './test-factories';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const DOC_ID = '22222222-2222-2222-2222-222222222222';
|
||||
|
||||
describe('LetterCard', () => {
|
||||
it('renders sender, receiver, and title', () => {
|
||||
render(LetterCard, {
|
||||
entry: makeEntry({ senderName: 'Karl', receiverName: 'Elfriede', title: 'Feldpost' })
|
||||
});
|
||||
expect(document.body.textContent).toContain('Karl');
|
||||
expect(document.body.textContent).toContain('Elfriede');
|
||||
expect(document.body.textContent).toContain('Feldpost');
|
||||
});
|
||||
|
||||
it('renders the precision date exactly as timelineDateLabel returns (REQ-013)', () => {
|
||||
const entry = makeEntry({ eventDate: '1915-06-15', precision: 'MONTH' });
|
||||
const expected = timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd);
|
||||
expect(expected).toBeTruthy();
|
||||
render(LetterCard, { entry });
|
||||
expect(document.body.textContent).toContain(expected as string);
|
||||
});
|
||||
|
||||
it('renders no date chip when timelineDateLabel returns null (REQ-013)', () => {
|
||||
const entry = makeEntry({ precision: 'UNKNOWN', eventDate: undefined });
|
||||
render(LetterCard, { entry });
|
||||
const chip = document.querySelector('[data-testid="letter-date"]');
|
||||
expect(chip).toBeNull();
|
||||
});
|
||||
|
||||
it('shows "Unbekannt" for an empty sender, never a bare arrow (REQ-014)', () => {
|
||||
render(LetterCard, { entry: makeEntry({ senderName: '', receiverName: 'Elfriede' }) });
|
||||
expect(document.body.textContent).toContain('Unbekannt');
|
||||
});
|
||||
|
||||
it('shows "Unbekannt" for an empty receiver (REQ-014)', () => {
|
||||
render(LetterCard, { entry: makeEntry({ senderName: 'Karl', receiverName: '' }) });
|
||||
expect(document.body.textContent).toContain('Unbekannt');
|
||||
});
|
||||
|
||||
it('links to exactly /documents/{documentId} with no target (REQ-023)', () => {
|
||||
render(LetterCard, { entry: makeEntry({ documentId: DOC_ID }) });
|
||||
const link = document.querySelector('a') as HTMLAnchorElement;
|
||||
expect(link.getAttribute('href')).toBe(`/documents/${DOC_ID}`);
|
||||
expect(link.hasAttribute('target')).toBe(false);
|
||||
});
|
||||
|
||||
it('has a touch target of at least 44px (REQ-020)', () => {
|
||||
render(LetterCard, { entry: makeEntry() });
|
||||
const link = document.querySelector('a') as HTMLAnchorElement;
|
||||
expect(link.getBoundingClientRect().height).toBeGreaterThanOrEqual(44);
|
||||
});
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import YearBand from './YearBand.svelte';
|
||||
import GapSpan from './GapSpan.svelte';
|
||||
import LetterCard from './LetterCard.svelte';
|
||||
import EventPill from './EventPill.svelte';
|
||||
import WorldBand from './WorldBand.svelte';
|
||||
import { entryKey } from './entryKey';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type TimelineDTO = components['schemas']['TimelineDTO'];
|
||||
type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
|
||||
|
||||
/**
|
||||
* Orchestrates the global timeline (REQ-001/003). Renders the year bands the DTO
|
||||
* delivers in order — never re-sorting — interleaving a folded GapSpan for each
|
||||
* interior run of empty years (REQ-015), then the undated bucket (REQ-016). An
|
||||
* empty timeline shows a calm message (REQ-017). `personId` is a declared seam
|
||||
* for the per-person rail (issue #10) and is undefined here; it is not passed to
|
||||
* leaf cards (REQ-025). Owns no <main> — the layout does.
|
||||
*/
|
||||
let { timeline, personId = undefined }: { timeline: TimelineDTO; personId?: string } = $props();
|
||||
|
||||
type Row = { t: 'band'; year: TimelineYearDTO } | { t: 'gap'; from: number; to: number };
|
||||
|
||||
const rows = $derived.by<Row[]>(() => {
|
||||
const out: Row[] = [];
|
||||
const years = timeline.years;
|
||||
for (let i = 0; i < years.length; i++) {
|
||||
if (i > 0) {
|
||||
const prev = years[i - 1].year;
|
||||
const cur = years[i].year;
|
||||
if (cur - prev > 1) out.push({ t: 'gap', from: prev + 1, to: cur - 1 });
|
||||
}
|
||||
out.push({ t: 'band', year: years[i] });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const isEmpty = $derived(timeline.years.length === 0 && timeline.undated.length === 0);
|
||||
</script>
|
||||
|
||||
{#if isEmpty}
|
||||
<p class="py-12 text-center font-serif text-base text-ink-2">{m.timeline_empty_state()}</p>
|
||||
{:else}
|
||||
<!-- personId is a declared seam for the per-person Lebensweg rail (issue #10);
|
||||
undefined in the global view, surfaced only on the root, never passed to
|
||||
leaf cards (REQ-025). -->
|
||||
<ol class="timeline-axis relative mx-auto max-w-3xl" data-person-id={personId}>
|
||||
{#each rows as row (row.t === 'band' ? `band-${row.year.year}` : `gap-${row.from}`)}
|
||||
<li>
|
||||
{#if row.t === 'band'}
|
||||
<YearBand year={row.year} />
|
||||
{:else}
|
||||
<GapSpan from={row.from} to={row.to} />
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
{#if timeline.undated.length > 0}
|
||||
<section data-testid="undated-section" class="mx-auto mt-8 max-w-3xl">
|
||||
<h2 class="mb-3 font-serif text-sm font-bold text-ink-2">{m.timeline_undated_section()}</h2>
|
||||
<ul class="space-y-2">
|
||||
<!-- The undated bucket is filtered from ALL entries, so it can hold
|
||||
events as well as letters. Dispatch on kind/type exactly like
|
||||
YearBand — an event rendered as a LetterCard would link to
|
||||
/documents/undefined and read "Unknown → Unknown" (REQ-007/008/009). -->
|
||||
{#each timeline.undated as entry (entryKey(entry))}
|
||||
<li>
|
||||
{#if entry.kind === 'EVENT'}
|
||||
{#if entry.type === 'HISTORICAL'}
|
||||
<WorldBand entry={entry} />
|
||||
{:else}
|
||||
<EventPill entry={entry} />
|
||||
{/if}
|
||||
{:else}
|
||||
<LetterCard entry={entry} />
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Phone (< 1024px): a single left-anchored spine. Desktop (≥ 1024px): a
|
||||
centered spine the bands' alternating cards sit on either side of. The
|
||||
spine is decorative — the chronology lives in the <ol> DOM order. */
|
||||
.timeline-axis::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0.5rem;
|
||||
width: 2px;
|
||||
background: linear-gradient(var(--palette-mint), var(--palette-navy));
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.timeline-axis::before {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,237 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import TimelineView from './TimelineView.svelte';
|
||||
import { makeEntry, makeYear, makeTimelineDTO } from './test-factories';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('TimelineView', () => {
|
||||
it('shows the empty state and no ol when there are no years and no undated (REQ-017)', () => {
|
||||
render(TimelineView, { timeline: makeTimelineDTO() });
|
||||
expect(document.body.textContent).toContain('Noch keine Ereignisse.');
|
||||
expect(document.querySelector('ol')).toBeNull();
|
||||
expect(document.querySelector('section')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the timeline as a single <ol> with each band a <section>, ascending (REQ-006)', () => {
|
||||
render(TimelineView, {
|
||||
timeline: makeTimelineDTO({
|
||||
years: [
|
||||
makeYear(1914, [makeEntry({ documentId: 'a' })]),
|
||||
makeYear(1916, [makeEntry({ documentId: 'b' })])
|
||||
]
|
||||
})
|
||||
});
|
||||
expect(document.querySelectorAll('ol')).toHaveLength(1);
|
||||
const headings = Array.from(document.querySelectorAll('section h2')).map((h) => h.textContent);
|
||||
expect(headings.some((t) => t?.includes('1914'))).toBe(true);
|
||||
const order = headings.map((t) => t?.trim());
|
||||
expect(order.indexOf('1914')).toBeLessThan(order.indexOf('1916'));
|
||||
});
|
||||
|
||||
it('folds an interior run of empty years into one GapSpan (REQ-015)', () => {
|
||||
render(TimelineView, {
|
||||
timeline: makeTimelineDTO({
|
||||
years: [
|
||||
makeYear(1909, [makeEntry({ documentId: 'a' })]),
|
||||
makeYear(1915, [makeEntry({ documentId: 'b' })])
|
||||
]
|
||||
})
|
||||
});
|
||||
expect(document.body.textContent).toContain('1910–1914');
|
||||
expect(document.body.textContent).toContain('keine Einträge');
|
||||
});
|
||||
|
||||
it('folds a single empty interior year as a single year (REQ-015)', () => {
|
||||
render(TimelineView, {
|
||||
timeline: makeTimelineDTO({
|
||||
years: [
|
||||
makeYear(1911, [makeEntry({ documentId: 'a' })]),
|
||||
makeYear(1913, [makeEntry({ documentId: 'b' })])
|
||||
]
|
||||
})
|
||||
});
|
||||
expect(document.body.textContent).toContain('1912');
|
||||
expect(document.body.textContent).not.toContain('1912–1912');
|
||||
});
|
||||
|
||||
it('renders an "Ohne Datum" section when undated is non-empty (REQ-016)', () => {
|
||||
render(TimelineView, {
|
||||
timeline: makeTimelineDTO({
|
||||
years: [makeYear(1914, [makeEntry({ documentId: 'a' })])],
|
||||
undated: [makeEntry({ precision: 'UNKNOWN', eventDate: undefined, documentId: 'u1' })]
|
||||
})
|
||||
});
|
||||
expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
|
||||
expect(document.body.textContent).toContain('Ohne Datum');
|
||||
});
|
||||
|
||||
it('omits the "Ohne Datum" section from the DOM when undated is empty (REQ-016)', () => {
|
||||
render(TimelineView, {
|
||||
timeline: makeTimelineDTO({ years: [makeYear(1914, [makeEntry({ documentId: 'a' })])] })
|
||||
});
|
||||
expect(document.querySelector('[data-testid="undated-section"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders all years and undated entries with personId undefined, no filtering (REQ-025)', () => {
|
||||
render(TimelineView, {
|
||||
timeline: makeTimelineDTO({
|
||||
years: [
|
||||
makeYear(1914, [makeEntry({ documentId: 'a' })]),
|
||||
makeYear(1915, [makeEntry({ documentId: 'b' })])
|
||||
],
|
||||
undated: [makeEntry({ precision: 'UNKNOWN', eventDate: undefined, documentId: 'u1' })]
|
||||
}),
|
||||
personId: undefined
|
||||
});
|
||||
// Two year bands inside the <ol>, plus the separate undated section.
|
||||
expect(document.querySelectorAll('ol section h2')).toHaveLength(2);
|
||||
expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders an undated curated EVENT as a pill, not a broken letter card (REQ-008/016)', () => {
|
||||
render(TimelineView, {
|
||||
timeline: makeTimelineDTO({
|
||||
undated: [
|
||||
makeEntry({
|
||||
kind: 'EVENT',
|
||||
type: 'PERSONAL',
|
||||
derived: false,
|
||||
eventId: 'e1',
|
||||
precision: 'UNKNOWN',
|
||||
eventDate: undefined,
|
||||
title: 'Auswanderung',
|
||||
senderName: '',
|
||||
receiverName: '',
|
||||
documentId: undefined
|
||||
})
|
||||
]
|
||||
})
|
||||
});
|
||||
// The event renders inside the undated section…
|
||||
expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
|
||||
expect(document.body.textContent).toContain('Auswanderung');
|
||||
// …as an EventPill (its edit affordance), never as a letter card linking
|
||||
// to /documents/undefined with "Unbekannt → Unbekannt".
|
||||
expect(document.querySelector('[data-testid="event-edit"]')).not.toBeNull();
|
||||
expect(document.querySelector('a[href="/documents/undefined"]')).toBeNull();
|
||||
expect(document.body.textContent).not.toContain('Unbekannt');
|
||||
});
|
||||
|
||||
it('renders an undated HISTORICAL EVENT as a world band, not a letter card (REQ-009/016)', () => {
|
||||
render(TimelineView, {
|
||||
timeline: makeTimelineDTO({
|
||||
undated: [
|
||||
makeEntry({
|
||||
kind: 'EVENT',
|
||||
type: 'HISTORICAL',
|
||||
derived: false,
|
||||
precision: 'UNKNOWN',
|
||||
eventDate: undefined,
|
||||
title: 'Weltwirtschaftskrise',
|
||||
senderName: '',
|
||||
receiverName: '',
|
||||
documentId: undefined
|
||||
})
|
||||
]
|
||||
})
|
||||
});
|
||||
expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
|
||||
expect(document.body.textContent).toContain('Weltwirtschaftskrise');
|
||||
// HISTORICAL → WorldBand carries the sr-only "Weltgeschehen" cue (REQ-018),
|
||||
// not a broken document link.
|
||||
expect(document.body.textContent).toContain('Weltgeschehen');
|
||||
expect(document.querySelector('a[href="/documents/undefined"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('still renders an undated LETTER as a letter card (REQ-016)', () => {
|
||||
render(TimelineView, {
|
||||
timeline: makeTimelineDTO({
|
||||
undated: [makeEntry({ precision: 'UNKNOWN', eventDate: undefined, documentId: 'u1' })]
|
||||
})
|
||||
});
|
||||
expect(document.querySelector('a[href="/documents/u1"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders two derived events in one band without key collision (no-double-null-key)', () => {
|
||||
const a = makeEntry({
|
||||
kind: 'EVENT',
|
||||
derived: true,
|
||||
derivedType: 'BIRTH',
|
||||
title: 'Geburt: Anna',
|
||||
senderName: '',
|
||||
receiverName: '',
|
||||
documentId: undefined,
|
||||
eventId: undefined,
|
||||
linkedPersonIds: ['p1']
|
||||
});
|
||||
const b = makeEntry({
|
||||
kind: 'EVENT',
|
||||
derived: true,
|
||||
derivedType: 'BIRTH',
|
||||
title: 'Geburt: Bertha',
|
||||
senderName: '',
|
||||
receiverName: '',
|
||||
documentId: undefined,
|
||||
eventId: undefined,
|
||||
linkedPersonIds: ['p2']
|
||||
});
|
||||
render(TimelineView, { timeline: makeTimelineDTO({ years: [makeYear(1915, [a, b])] }) });
|
||||
expect(document.body.textContent).toContain('Geburt: Anna');
|
||||
expect(document.body.textContent).toContain('Geburt: Bertha');
|
||||
});
|
||||
|
||||
it('shows the redundant non-color cue label for each layer (REQ-018)', () => {
|
||||
render(TimelineView, {
|
||||
timeline: makeTimelineDTO({
|
||||
years: [
|
||||
makeYear(1914, [
|
||||
makeEntry({
|
||||
kind: 'EVENT',
|
||||
derived: true,
|
||||
derivedType: 'BIRTH',
|
||||
title: 'Geburt: Hans',
|
||||
senderName: '',
|
||||
receiverName: '',
|
||||
documentId: undefined
|
||||
}),
|
||||
makeEntry({
|
||||
kind: 'EVENT',
|
||||
derived: false,
|
||||
type: 'PERSONAL',
|
||||
eventId: 'e1',
|
||||
title: 'Auswanderung',
|
||||
senderName: '',
|
||||
receiverName: '',
|
||||
documentId: undefined
|
||||
}),
|
||||
makeEntry({
|
||||
kind: 'EVENT',
|
||||
derived: false,
|
||||
type: 'HISTORICAL',
|
||||
precision: 'RANGE',
|
||||
eventDate: '1914-01-01',
|
||||
eventDateEnd: '1918-12-31',
|
||||
title: 'Erster Weltkrieg',
|
||||
senderName: '',
|
||||
receiverName: '',
|
||||
documentId: undefined
|
||||
})
|
||||
])
|
||||
]
|
||||
})
|
||||
});
|
||||
expect(document.body.textContent).toContain('Weltgeschehen');
|
||||
expect(document.body.textContent).toContain('Familie');
|
||||
expect(document.body.textContent).toContain('Geburt');
|
||||
});
|
||||
|
||||
it('places consecutive letter cards on alternating sides (REQ-004 surrogate)', () => {
|
||||
const letters = Array.from({ length: 4 }, (_, i) => makeEntry({ documentId: `d${i}` }));
|
||||
render(TimelineView, { timeline: makeTimelineDTO({ years: [makeYear(1909, letters)] }) });
|
||||
const sides = Array.from(document.querySelectorAll('.letter-row')).map((el) =>
|
||||
el.getAttribute('data-side')
|
||||
);
|
||||
expect(sides).toEqual(['left', 'right', 'left', 'right']);
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { getAccentConfig } from './eventCardConfig';
|
||||
import { timelineDateLabel } from './dateLabel';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
||||
|
||||
/**
|
||||
* Full-width muted band for a HISTORICAL event, laid across the axis as context
|
||||
* (REQ-009). A RANGE carries a visible span pill ("1914–1918") with a Zeitraum
|
||||
* aria-label; a RANGE with no end degrades to the start year, no pill (REQ-010).
|
||||
* The glyph is a redundant non-color cue with an sr-only label (REQ-018); text
|
||||
* uses text-ink-2 to stay AA in both themes (REQ-019).
|
||||
*/
|
||||
let { entry }: { entry: TimelineEntryDTO } = $props();
|
||||
|
||||
const config = $derived(getAccentConfig(entry));
|
||||
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
|
||||
const fromYear = $derived(entry.eventDate ? entry.eventDate.slice(0, 4) : null);
|
||||
const toYear = $derived(entry.eventDateEnd ? entry.eventDateEnd.slice(0, 4) : null);
|
||||
const showSpan = $derived(entry.precision === 'RANGE' && fromYear != null && toYear != null);
|
||||
const dateText = $derived(showSpan ? null : entry.precision === 'RANGE' ? fromYear : dateLabel);
|
||||
</script>
|
||||
|
||||
<div class="my-3 border-y border-line bg-canvas px-4 py-2 text-center">
|
||||
<span class="font-serif text-sm text-ink-2 italic">
|
||||
<span aria-hidden="true" style="color: var(--c-tag-slate)">{config.glyph}</span>
|
||||
<span class="sr-only">{config.label}</span>
|
||||
{entry.title}
|
||||
</span>
|
||||
{#if showSpan && fromYear && toYear}
|
||||
<span
|
||||
data-testid="world-range"
|
||||
class="ml-2 inline-block rounded-full border border-line px-2 py-0.5 font-sans text-xs text-ink-2"
|
||||
aria-label={m.timeline_range_aria({ from: fromYear, to: toYear })}
|
||||
>
|
||||
{fromYear}–{toYear}
|
||||
</span>
|
||||
{:else if dateText}
|
||||
<span class="ml-2 font-sans text-xs text-ink-3">{dateText}</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,48 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import WorldBand from './WorldBand.svelte';
|
||||
import { makeEntry } from './test-factories';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
function historical(overrides = {}) {
|
||||
return makeEntry({
|
||||
kind: 'EVENT',
|
||||
derived: false,
|
||||
type: 'HISTORICAL',
|
||||
title: 'Erster Weltkrieg',
|
||||
senderName: '',
|
||||
receiverName: '',
|
||||
precision: 'RANGE',
|
||||
eventDate: '1914-01-01',
|
||||
eventDateEnd: '1918-12-31',
|
||||
documentId: undefined,
|
||||
...overrides
|
||||
});
|
||||
}
|
||||
|
||||
describe('WorldBand', () => {
|
||||
it('renders the historical title with the world glyph + "Weltgeschehen" cue (REQ-018)', () => {
|
||||
render(WorldBand, { entry: historical() });
|
||||
expect(document.body.textContent).toContain('Erster Weltkrieg');
|
||||
const hidden = document.querySelector('[aria-hidden="true"]');
|
||||
expect(hidden?.textContent).toBe('◍');
|
||||
const srOnly = document.querySelector('.sr-only');
|
||||
expect(srOnly?.textContent).toBe('Weltgeschehen');
|
||||
});
|
||||
|
||||
it('renders a RANGE span pill 1914–1918 with a Zeitraum aria-label (REQ-009)', () => {
|
||||
render(WorldBand, { entry: historical() });
|
||||
const pill = document.querySelector('[data-testid="world-range"]');
|
||||
expect(pill).not.toBeNull();
|
||||
expect(pill?.textContent).toContain('1914–1918');
|
||||
expect(pill?.getAttribute('aria-label')).toBe('Zeitraum: 1914 bis 1918');
|
||||
});
|
||||
|
||||
it('degrades a RANGE with no end to the start year, no span pill, no crash (REQ-010)', () => {
|
||||
render(WorldBand, { entry: historical({ eventDateEnd: undefined }) });
|
||||
expect(document.querySelector('[data-testid="world-range"]')).toBeNull();
|
||||
expect(document.body.textContent).toContain('Erster Weltkrieg');
|
||||
expect(document.body.textContent).toContain('1914');
|
||||
});
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
<script lang="ts">
|
||||
import EventPill from './EventPill.svelte';
|
||||
import WorldBand from './WorldBand.svelte';
|
||||
import LetterCard from './LetterCard.svelte';
|
||||
import YearLetterStrip from './YearLetterStrip.svelte';
|
||||
import { isDense } from './timelineDensity';
|
||||
import { entryKey } from './entryKey';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
|
||||
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
||||
|
||||
/**
|
||||
* One year of the timeline: a <section> with a sticky <h2> (REQ-006). Events
|
||||
* render in DTO order as pills/bands; letters render as individual cards while
|
||||
* the band holds ≤ 12 (REQ-011) or collapse to a single density strip above that
|
||||
* (REQ-012). Entries are never re-sorted — DTO order is preserved (REQ-003).
|
||||
*/
|
||||
let { year }: { year: TimelineYearDTO } = $props();
|
||||
|
||||
type Row =
|
||||
| { t: 'event'; entry: TimelineEntryDTO }
|
||||
| { t: 'letter'; entry: TimelineEntryDTO; side: 'left' | 'right' }
|
||||
| { t: 'strip' };
|
||||
|
||||
const letters = $derived(year.entries.filter((e) => e.kind === 'LETTER'));
|
||||
const dense = $derived(isDense(letters.length));
|
||||
|
||||
const rows = $derived.by<Row[]>(() => {
|
||||
const out: Row[] = [];
|
||||
let stripInserted = false;
|
||||
let letterIndex = 0;
|
||||
for (const entry of year.entries) {
|
||||
if (entry.kind === 'EVENT') {
|
||||
out.push({ t: 'event', entry });
|
||||
} else if (!dense) {
|
||||
out.push({ t: 'letter', entry, side: letterIndex % 2 === 0 ? 'left' : 'right' });
|
||||
letterIndex += 1;
|
||||
} else if (!stripInserted) {
|
||||
out.push({ t: 'strip' });
|
||||
stripInserted = true;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="py-2">
|
||||
<h2
|
||||
class="year-heading w-fit rounded-full bg-brand-navy px-4 py-1 font-serif text-sm font-bold text-white"
|
||||
>
|
||||
{year.year}
|
||||
</h2>
|
||||
|
||||
<div class="mt-3 space-y-3">
|
||||
{#each rows as row (row.t === 'strip' ? `strip-${year.year}` : entryKey(row.entry))}
|
||||
{#if row.t === 'event'}
|
||||
{#if row.entry.type === 'HISTORICAL'}
|
||||
<WorldBand entry={row.entry} />
|
||||
{:else}
|
||||
<EventPill entry={row.entry} />
|
||||
{/if}
|
||||
{:else if row.t === 'letter'}
|
||||
<div class="letter-row" data-side={row.side}>
|
||||
<LetterCard entry={row.entry} />
|
||||
</div>
|
||||
{:else}
|
||||
<YearLetterStrip letters={letters} year={year.year} />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Sticky offset in scoped CSS so it holds in unit tests too (the global
|
||||
header is a 64px sticky nav). REQ-006. */
|
||||
.year-heading {
|
||||
position: sticky;
|
||||
top: 4rem;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Phone (< 1024px): single left-anchored column, all letters on one side
|
||||
(REQ-005). Desktop (≥ 1024px): centered axis, letters alternate left/right
|
||||
so consecutive cards sit on opposite sides of the spine (REQ-004). */
|
||||
@media (min-width: 1024px) {
|
||||
.letter-row {
|
||||
width: 50%;
|
||||
}
|
||||
.letter-row[data-side='left'] {
|
||||
margin-right: auto;
|
||||
padding-right: 1.75rem;
|
||||
}
|
||||
.letter-row[data-side='right'] {
|
||||
margin-left: auto;
|
||||
padding-left: 1.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,84 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import YearBand from './YearBand.svelte';
|
||||
import { makeEntry, makeYear } from './test-factories';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
function manyLetters(year: number, count: number) {
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
makeEntry({ eventDate: `${year}-01-10`, documentId: `doc-${i}` })
|
||||
);
|
||||
}
|
||||
|
||||
describe('YearBand', () => {
|
||||
it('renders a section with a sticky h2 at top:4rem showing the year (REQ-006)', () => {
|
||||
render(YearBand, { year: makeYear(1914, [makeEntry()]) });
|
||||
const section = document.querySelector('section');
|
||||
expect(section).not.toBeNull();
|
||||
const h2 = section?.querySelector('h2');
|
||||
expect(h2?.textContent).toContain('1914');
|
||||
const cs = getComputedStyle(h2 as HTMLElement);
|
||||
expect(cs.position).toBe('sticky');
|
||||
expect(cs.top).toBe('64px');
|
||||
});
|
||||
|
||||
it('renders each letter as a card when the band holds <= 12 letters (REQ-011)', () => {
|
||||
render(YearBand, { year: makeYear(1909, manyLetters(1909, 3)) });
|
||||
expect(document.querySelectorAll('a')).toHaveLength(3);
|
||||
expect(document.querySelector('[data-testid="strip-expand"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders a single strip when the band holds > 12 letters (REQ-012)', () => {
|
||||
render(YearBand, { year: makeYear(1915, manyLetters(1915, 30)) });
|
||||
expect(document.querySelector('[data-testid="strip-expand"]')).not.toBeNull();
|
||||
// collapsed: no individual letter links yet
|
||||
expect(document.querySelectorAll('a')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders entries in DTO order — DAY-precision letter above a YEAR-precision letter (REQ-003)', () => {
|
||||
const dayLetter = makeEntry({
|
||||
precision: 'DAY',
|
||||
eventDate: '1923-04-12',
|
||||
title: 'Tagesgenau',
|
||||
documentId: 'day'
|
||||
});
|
||||
const yearLetter = makeEntry({
|
||||
precision: 'YEAR',
|
||||
eventDate: '1923-01-01',
|
||||
title: 'Nur Jahr',
|
||||
documentId: 'year'
|
||||
});
|
||||
render(YearBand, { year: makeYear(1923, [dayLetter, yearLetter]) });
|
||||
const links = Array.from(document.querySelectorAll('a'));
|
||||
expect(links[0].getAttribute('href')).toBe('/documents/day');
|
||||
expect(links[1].getAttribute('href')).toBe('/documents/year');
|
||||
});
|
||||
|
||||
it('renders an EVENT as a pill and a HISTORICAL event as a band', () => {
|
||||
const pill = makeEntry({
|
||||
kind: 'EVENT',
|
||||
derived: true,
|
||||
derivedType: 'MARRIAGE',
|
||||
title: 'Heirat',
|
||||
senderName: '',
|
||||
receiverName: '',
|
||||
documentId: undefined
|
||||
});
|
||||
const band = makeEntry({
|
||||
kind: 'EVENT',
|
||||
derived: false,
|
||||
type: 'HISTORICAL',
|
||||
precision: 'RANGE',
|
||||
eventDate: '1914-01-01',
|
||||
eventDateEnd: '1918-12-31',
|
||||
title: 'Erster Weltkrieg',
|
||||
senderName: '',
|
||||
receiverName: '',
|
||||
documentId: undefined
|
||||
});
|
||||
render(YearBand, { year: makeYear(1914, [pill, band]) });
|
||||
expect(document.body.textContent).toContain('Heirat');
|
||||
expect(document.querySelector('[data-testid="world-range"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import Sparkline from '$lib/shared/primitives/Sparkline.svelte';
|
||||
import LetterCard from './LetterCard.svelte';
|
||||
import { monthHistogram } from './timelineDensity';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
||||
|
||||
/**
|
||||
* Compact density view for a year with many letters (REQ-012): the letter count
|
||||
* plus a 12-month density sparkline, and a ≥44px keyboard-focusable toggle that
|
||||
* expands to that year's individual LetterCards.
|
||||
*/
|
||||
let { letters, year }: { letters: TimelineEntryDTO[]; year: number } = $props();
|
||||
|
||||
let expanded = $state(false);
|
||||
|
||||
const counts = $derived(monthHistogram(letters, year).map((b) => b.count));
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-md rounded-sm border border-line bg-surface p-3 shadow-sm">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="font-sans text-sm font-bold text-brand-navy"
|
||||
>{m.timeline_letters_count({ count: letters.length })}</span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="strip-expand"
|
||||
aria-expanded={expanded}
|
||||
onclick={() => (expanded = !expanded)}
|
||||
style="display: inline-flex; align-items: center; min-height: 44px"
|
||||
class="rounded-sm px-2 font-sans text-xs text-ink-3 hover:text-brand-navy focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||
>
|
||||
{m.timeline_strip_expand()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Sparkline
|
||||
values={counts}
|
||||
label={m.timeline_letters_count({ count: letters.length })}
|
||||
class="mt-2"
|
||||
/>
|
||||
|
||||
{#if expanded}
|
||||
<ul class="mt-3 space-y-2">
|
||||
{#each letters as letter (letter.documentId)}
|
||||
<li><LetterCard entry={letter} /></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,42 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import YearLetterStrip from './YearLetterStrip.svelte';
|
||||
import { makeEntry } from './test-factories';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
function denseLetters(year: number, count: number) {
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
makeEntry({
|
||||
eventDate: `${year}-${String((i % 12) + 1).padStart(2, '0')}-10`,
|
||||
documentId: `doc-${i}`
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
describe('YearLetterStrip', () => {
|
||||
it('shows the letter count and a 12-bar sparkline (REQ-012)', () => {
|
||||
render(YearLetterStrip, { letters: denseLetters(1915, 30), year: 1915 });
|
||||
expect(document.body.textContent).toContain('30');
|
||||
const bars = document.querySelectorAll('[data-testid="sparkline-bar"]');
|
||||
expect(bars).toHaveLength(12);
|
||||
});
|
||||
|
||||
it('has a keyboard-focusable expand toggle of at least 44px (REQ-012)', () => {
|
||||
render(YearLetterStrip, { letters: denseLetters(1915, 30), year: 1915 });
|
||||
const toggle = document.querySelector('[data-testid="strip-expand"]') as HTMLButtonElement;
|
||||
expect(toggle).not.toBeNull();
|
||||
expect(toggle.tagName).toBe('BUTTON');
|
||||
expect(toggle.getBoundingClientRect().height).toBeGreaterThanOrEqual(44);
|
||||
});
|
||||
|
||||
it('reveals all letter cards when expanded (REQ-012)', async () => {
|
||||
render(YearLetterStrip, { letters: denseLetters(1915, 30), year: 1915 });
|
||||
expect(document.querySelectorAll('a').length).toBe(0);
|
||||
const toggle = document.querySelector('[data-testid="strip-expand"]') as HTMLButtonElement;
|
||||
toggle.click();
|
||||
await tick();
|
||||
expect(document.querySelectorAll('a').length).toBe(30);
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
||||
|
||||
/**
|
||||
* Stable `{#each}` key for a timeline entry. Prefers the entry's own identity
|
||||
* (`eventId` for curated events, `documentId` for letters); derived life-events
|
||||
* carry neither, so they key on `derivedType` + their linked person ids — which
|
||||
* keeps two derived births in the same year distinct. The `kind` prefix keeps an
|
||||
* event and a letter that happen to share an id from colliding.
|
||||
*
|
||||
* Used by both `YearBand` (per-band rows) and `TimelineView` (the undated
|
||||
* bucket), where entries can be events without a `documentId`.
|
||||
*/
|
||||
export function entryKey(entry: TimelineEntryDTO): string {
|
||||
return (
|
||||
entry.kind +
|
||||
':' +
|
||||
(entry.eventId ??
|
||||
entry.documentId ??
|
||||
`${entry.derivedType}:${(entry.linkedPersonIds ?? []).join('-')}`)
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getAccentConfig } from './eventCardConfig';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
||||
|
||||
function event(overrides: Partial<TimelineEntryDTO>): TimelineEntryDTO {
|
||||
return {
|
||||
kind: 'EVENT',
|
||||
precision: 'YEAR',
|
||||
derived: false,
|
||||
senderName: '',
|
||||
receiverName: '',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('getAccentConfig', () => {
|
||||
it('maps a derived birth to the * glyph and "Geburt"', () => {
|
||||
const cfg = getAccentConfig(event({ derived: true, derivedType: 'BIRTH' }));
|
||||
expect(cfg.glyph).toBe('*');
|
||||
expect(cfg.label).toBe('Geburt');
|
||||
expect(cfg.accent).toBe('derived');
|
||||
});
|
||||
|
||||
it('maps a derived death to the † glyph and "Tod"', () => {
|
||||
const cfg = getAccentConfig(event({ derived: true, derivedType: 'DEATH' }));
|
||||
expect(cfg.glyph).toBe('†');
|
||||
expect(cfg.label).toBe('Tod');
|
||||
expect(cfg.accent).toBe('derived');
|
||||
});
|
||||
|
||||
it('maps a derived marriage to the ⚭ glyph and "Heirat"', () => {
|
||||
const cfg = getAccentConfig(event({ derived: true, derivedType: 'MARRIAGE' }));
|
||||
expect(cfg.glyph).toBe('⚭');
|
||||
expect(cfg.label).toBe('Heirat');
|
||||
expect(cfg.accent).toBe('derived');
|
||||
});
|
||||
|
||||
it('maps a HISTORICAL event to the world glyph and "Weltgeschehen"', () => {
|
||||
const cfg = getAccentConfig(event({ type: 'HISTORICAL' }));
|
||||
expect(cfg.glyph).toBe('◍');
|
||||
expect(cfg.label).toBe('Weltgeschehen');
|
||||
expect(cfg.accent).toBe('historical');
|
||||
});
|
||||
|
||||
it('maps a curated PERSONAL event to the ★ glyph and "Familie"', () => {
|
||||
const cfg = getAccentConfig(event({ type: 'PERSONAL', eventId: 'e-1' }));
|
||||
expect(cfg.glyph).toBe('★');
|
||||
expect(cfg.label).toBe('Familie');
|
||||
expect(cfg.accent).toBe('curated');
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
||||
|
||||
/** Styling discriminant for an axis pill/band. */
|
||||
export type TimelineAccent = 'derived' | 'curated' | 'historical';
|
||||
|
||||
export interface AccentConfig {
|
||||
/** Visible Unicode glyph — render `aria-hidden`, paired with an sr-only label. */
|
||||
glyph: string;
|
||||
/** Localized layer/life-event label — used as the sr-only / aria text only. */
|
||||
label: string;
|
||||
accent: TimelineAccent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a timeline EVENT entry to its glyph, redundant non-color label, and accent
|
||||
* (REQ-007/008/018). Derived life-events use the * / † / ⚭ glyphs that match
|
||||
* `personLifeDates.ts`; HISTORICAL events get the muted world band; everything
|
||||
* else (curated PERSONAL) gets the mint family pill.
|
||||
*/
|
||||
export function getAccentConfig(entry: TimelineEntryDTO): AccentConfig {
|
||||
if (entry.derived) {
|
||||
switch (entry.derivedType) {
|
||||
case 'BIRTH':
|
||||
return { glyph: '*', label: m.timeline_derived_birth(), accent: 'derived' };
|
||||
case 'DEATH':
|
||||
return { glyph: '†', label: m.timeline_derived_death(), accent: 'derived' };
|
||||
case 'MARRIAGE':
|
||||
return { glyph: '⚭', label: m.timeline_derived_marriage(), accent: 'derived' };
|
||||
}
|
||||
}
|
||||
if (entry.type === 'HISTORICAL') {
|
||||
return { glyph: '◍', label: m.timeline_layer_world(), accent: 'historical' };
|
||||
}
|
||||
return { glyph: '★', label: m.timeline_layer_family(), accent: 'curated' };
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { createApiClient } from '$lib/shared/api.server';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import type { DatePrecision } from '$lib/shared/utils/documentDate';
|
||||
import type { PersonOption } from '$lib/person/personOption';
|
||||
import type { DocumentOption } from '$lib/document/documentTypeahead';
|
||||
|
||||
type TimelineEventRequest = components['schemas']['TimelineEventRequest'];
|
||||
type ApiClient = ReturnType<typeof createApiClient>;
|
||||
|
||||
// Prevents open redirect: validate before constructing /persons/{id}. See OWASP CWE-601.
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
// Whitelist of accepted precision tokens — mirrors the DatePrecision union. Any
|
||||
// other submitted value falls back to DAY rather than flowing untrusted into the
|
||||
// request body (the backend enum is the hard gate; this keeps the two symmetric
|
||||
// with the `type` narrowing below).
|
||||
const VALID_PRECISIONS: readonly DatePrecision[] = [
|
||||
'DAY',
|
||||
'MONTH',
|
||||
'SEASON',
|
||||
'YEAR',
|
||||
'RANGE',
|
||||
'APPROX',
|
||||
'UNKNOWN'
|
||||
];
|
||||
|
||||
/**
|
||||
* Resolves the context-aware post-save / post-delete redirect target. Returns
|
||||
* the originating person page only when `originPersonIdRaw` is a strict UUID;
|
||||
* otherwise falls back to the timeline (open-redirect guard).
|
||||
*/
|
||||
export function resolveNavTarget(originPersonIdRaw: string): string {
|
||||
return UUID_RE.test(originPersonIdRaw) ? `/persons/${originPersonIdRaw}` : '/zeitstrahl';
|
||||
}
|
||||
|
||||
export interface ParsedEventForm {
|
||||
title: string;
|
||||
type: 'PERSONAL' | 'HISTORICAL';
|
||||
eventDate: string;
|
||||
precision: DatePrecision;
|
||||
eventDateEnd: string | null;
|
||||
description: string;
|
||||
personIds: string[];
|
||||
documentIds: string[];
|
||||
originPersonId: string;
|
||||
}
|
||||
|
||||
/** Reads the curator event form fields out of submitted FormData. */
|
||||
export function parseEventForm(formData: FormData): ParsedEventForm {
|
||||
const rawType = formData.get('type')?.toString();
|
||||
const type = rawType === 'HISTORICAL' ? 'HISTORICAL' : 'PERSONAL';
|
||||
const rawPrecision = formData.get('precision')?.toString() as DatePrecision | undefined;
|
||||
const precision: DatePrecision =
|
||||
rawPrecision && VALID_PRECISIONS.includes(rawPrecision) ? rawPrecision : 'DAY';
|
||||
const endRaw = formData.get('eventDateEnd')?.toString().trim() ?? '';
|
||||
// Off-RANGE submits an empty string → null so a stale end-date never persists.
|
||||
const eventDateEnd = precision === 'RANGE' && endRaw ? endRaw : null;
|
||||
|
||||
return {
|
||||
title: formData.get('title')?.toString().trim() ?? '',
|
||||
type,
|
||||
eventDate: formData.get('eventDate')?.toString().trim() ?? '',
|
||||
precision,
|
||||
eventDateEnd,
|
||||
description: formData.get('description')?.toString().trim() ?? '',
|
||||
personIds: formData.getAll('personIds').map((v) => v.toString()),
|
||||
documentIds: formData.getAll('documentIds').map((v) => v.toString()),
|
||||
originPersonId: formData.get('originPersonId')?.toString() ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns both failing required-field errors (title + date) simultaneously, or
|
||||
* null when the form is valid. The route owns the `fail(400)` so it can enrich
|
||||
* the payload with the preserved field values and rehydrated picker selections.
|
||||
*/
|
||||
export function validateEventForm(
|
||||
parsed: ParsedEventForm
|
||||
): { titleError: string; dateError: string } | null {
|
||||
const titleError = parsed.title.length === 0 ? m.event_editor_title_required() : '';
|
||||
const dateError = parsed.eventDate.length === 0 ? m.event_editor_date_required() : '';
|
||||
if (!titleError && !dateError) return null;
|
||||
return { titleError, dateError };
|
||||
}
|
||||
|
||||
/** The entered field values echoed back in every `fail(...)` so the form re-renders without loss. */
|
||||
export function preservedFormFields(parsed: ParsedEventForm) {
|
||||
return {
|
||||
title: parsed.title,
|
||||
description: parsed.description,
|
||||
type: parsed.type,
|
||||
personIds: parsed.personIds,
|
||||
documentIds: parsed.documentIds
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetches the selected persons/documents by id so a `fail(400)` can re-render
|
||||
* the pickers with full chip labels — the form only resubmits bare ids, which
|
||||
* cannot rebuild a chip on their own (Decision 6 / REQ-010). Non-ok lookups are
|
||||
* swallowed: a since-deleted id silently drops from the picker rather than
|
||||
* leaking existence, mirroring the prefill path in the new-route load.
|
||||
*/
|
||||
export async function lookupSelections(
|
||||
api: ApiClient,
|
||||
personIds: string[],
|
||||
documentIds: string[]
|
||||
): Promise<{ persons: PersonOption[]; documents: DocumentOption[] }> {
|
||||
const [personResults, documentResults] = await Promise.all([
|
||||
Promise.all(personIds.map((id) => api.GET('/api/persons/{id}', { params: { path: { id } } }))),
|
||||
Promise.all(
|
||||
documentIds.map((id) => api.GET('/api/documents/{id}', { params: { path: { id } } }))
|
||||
)
|
||||
]);
|
||||
return {
|
||||
persons: personResults.filter((r) => r.response.ok && r.data).map((r) => r.data!),
|
||||
documents: documentResults
|
||||
.filter((r) => r.response.ok && r.data)
|
||||
.map((r) => ({
|
||||
id: r.data!.id,
|
||||
title: r.data!.title,
|
||||
documentDate: r.data!.documentDate,
|
||||
metaDatePrecision: r.data!.metaDatePrecision,
|
||||
metaDateEnd: r.data!.metaDateEnd
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/** Builds the TimelineEventRequest write body from parsed form fields. */
|
||||
export function toEventRequest(parsed: ParsedEventForm, version?: number): TimelineEventRequest {
|
||||
return {
|
||||
title: parsed.title,
|
||||
type: parsed.type,
|
||||
eventDate: parsed.eventDate,
|
||||
precision: parsed.precision,
|
||||
eventDateEnd: parsed.eventDateEnd,
|
||||
...(parsed.description ? { description: parsed.description } : {}),
|
||||
...(parsed.personIds.length ? { personIds: parsed.personIds } : {}),
|
||||
...(parsed.documentIds.length ? { documentIds: parsed.documentIds } : {}),
|
||||
...(version !== undefined ? { version } : {})
|
||||
} as TimelineEventRequest;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
||||
type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
|
||||
type TimelineDTO = components['schemas']['TimelineDTO'];
|
||||
|
||||
/**
|
||||
* Builds a `TimelineEntryDTO` mirroring the real wire shape (no `year`,
|
||||
* `description`, or `snippet` fields). Defaults to a dated DAY-precision letter;
|
||||
* override `kind`/`derived`/`type`/`derivedType` etc. for events.
|
||||
*/
|
||||
export function makeEntry(overrides: Partial<TimelineEntryDTO> = {}): TimelineEntryDTO {
|
||||
return {
|
||||
kind: 'LETTER',
|
||||
precision: 'DAY',
|
||||
derived: false,
|
||||
senderName: 'Karl Raddatz',
|
||||
receiverName: 'Elfriede Raddatz',
|
||||
eventDate: '1915-06-15',
|
||||
title: 'Brief aus dem Feld',
|
||||
documentId: '11111111-1111-1111-1111-111111111111',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
export function makeYear(year: number, entries: TimelineEntryDTO[]): TimelineYearDTO {
|
||||
return { year, entries };
|
||||
}
|
||||
|
||||
export function makeTimelineDTO(
|
||||
opts: { years?: TimelineYearDTO[]; undated?: TimelineEntryDTO[] } = {}
|
||||
): TimelineDTO {
|
||||
return { years: opts.years ?? [], undated: opts.undated ?? [] };
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isDense, monthHistogram, DENSE_THRESHOLD } from './timelineDensity';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
||||
|
||||
function letter(eventDate: string): TimelineEntryDTO {
|
||||
return {
|
||||
kind: 'LETTER',
|
||||
precision: 'DAY',
|
||||
derived: false,
|
||||
senderName: 'Karl',
|
||||
receiverName: 'Elfriede',
|
||||
eventDate
|
||||
};
|
||||
}
|
||||
|
||||
describe('isDense', () => {
|
||||
it('uses a threshold of 12', () => {
|
||||
expect(DENSE_THRESHOLD).toBe(12);
|
||||
});
|
||||
|
||||
it('is false at exactly 12 letters (still rendered as individual cards)', () => {
|
||||
expect(isDense(12)).toBe(false);
|
||||
});
|
||||
|
||||
it('is true above 12 letters (collapses to a strip)', () => {
|
||||
expect(isDense(13)).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for empty and small bands', () => {
|
||||
expect(isDense(0)).toBe(false);
|
||||
expect(isDense(3)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('monthHistogram', () => {
|
||||
it('returns exactly 12 buckets for the band year, Jan..Dec', () => {
|
||||
const buckets = monthHistogram([letter('1915-03-04')], 1915);
|
||||
expect(buckets).toHaveLength(12);
|
||||
expect(buckets.map((b) => b.month)).toEqual([
|
||||
'1915-01',
|
||||
'1915-02',
|
||||
'1915-03',
|
||||
'1915-04',
|
||||
'1915-05',
|
||||
'1915-06',
|
||||
'1915-07',
|
||||
'1915-08',
|
||||
'1915-09',
|
||||
'1915-10',
|
||||
'1915-11',
|
||||
'1915-12'
|
||||
]);
|
||||
});
|
||||
|
||||
it('counts each letter on its eventDate month; counts sum to the total', () => {
|
||||
// 30 letters spread one-or-more per month across 1915.
|
||||
const dist: Record<string, number> = {
|
||||
'01': 1,
|
||||
'02': 2,
|
||||
'03': 3,
|
||||
'04': 4,
|
||||
'05': 1,
|
||||
'06': 5,
|
||||
'07': 2,
|
||||
'08': 6,
|
||||
'09': 1,
|
||||
'10': 2,
|
||||
'11': 2,
|
||||
'12': 1
|
||||
};
|
||||
const letters: TimelineEntryDTO[] = [];
|
||||
for (const [mm, n] of Object.entries(dist)) {
|
||||
for (let i = 0; i < n; i++) letters.push(letter(`1915-${mm}-10`));
|
||||
}
|
||||
expect(letters).toHaveLength(30);
|
||||
|
||||
const buckets = monthHistogram(letters, 1915);
|
||||
expect(buckets.reduce((sum, b) => sum + b.count, 0)).toBe(30);
|
||||
for (const b of buckets) {
|
||||
expect(b.count).toBe(dist[b.month.slice(5)]);
|
||||
}
|
||||
});
|
||||
|
||||
it('yields height 0 for the eleven empty months when letters cluster in one', () => {
|
||||
const buckets = monthHistogram([letter('1915-03-01'), letter('1915-03-28')], 1915);
|
||||
const march = buckets.find((b) => b.month === '1915-03');
|
||||
expect(march?.count).toBe(2);
|
||||
expect(buckets.filter((b) => b.month !== '1915-03').every((b) => b.count === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('counts coarser-than-month precisions on their eventDate anchor month', () => {
|
||||
const seasonLetter: TimelineEntryDTO = { ...letter('1915-07-01'), precision: 'SEASON' };
|
||||
const buckets = monthHistogram([seasonLetter], 1915);
|
||||
expect(buckets.find((b) => b.month === '1915-07')?.count).toBe(1);
|
||||
});
|
||||
|
||||
it('ignores entries without an eventDate', () => {
|
||||
const undated: TimelineEntryDTO = {
|
||||
kind: 'LETTER',
|
||||
precision: 'UNKNOWN',
|
||||
derived: false,
|
||||
senderName: 'Karl',
|
||||
receiverName: 'Elfriede'
|
||||
};
|
||||
const buckets = monthHistogram([undated, letter('1915-05-01')], 1915);
|
||||
expect(buckets.reduce((sum, b) => sum + b.count, 0)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { fillDensityGaps, type MonthBucket } from '$lib/shared/utils/monthBuckets';
|
||||
|
||||
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
||||
|
||||
/**
|
||||
* A year band with more letters than this renders as a compact density strip
|
||||
* (count + 12-month sparkline) instead of one card per letter (REQ-012).
|
||||
*/
|
||||
export const DENSE_THRESHOLD = 12;
|
||||
|
||||
export function isDense(letterCount: number): boolean {
|
||||
return letterCount > DENSE_THRESHOLD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buckets a band's letters into exactly 12 month buckets (`{year}-01`..`{year}-12`)
|
||||
* for the density sparkline. Each letter counts on its `eventDate` month; coarser
|
||||
* precisions (SEASON/YEAR/APPROX) count on whatever anchor month the backend put
|
||||
* in `eventDate`. Entries without an `eventDate` (e.g. UNKNOWN) are ignored — they
|
||||
* live in the "Ohne Datum" bucket, not a dated band. (REQ-027)
|
||||
*/
|
||||
export function monthHistogram(letters: TimelineEntryDTO[], year: number): MonthBucket[] {
|
||||
const counts = new Map<string, number>();
|
||||
for (const l of letters) {
|
||||
if (!l.eventDate) continue;
|
||||
const month = l.eventDate.slice(0, 7); // YYYY-MM
|
||||
counts.set(month, (counts.get(month) ?? 0) + 1);
|
||||
}
|
||||
const buckets = Array.from(counts.entries()).map(([month, count]) => ({ month, count }));
|
||||
return fillDensityGaps(buckets, `${year}-01-01`, `${year}-12-31`);
|
||||
}
|
||||
@@ -78,16 +78,6 @@ function handleOverlayKeydown(event: KeyboardEvent) {
|
||||
>
|
||||
{m.nav_geschichten()}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/zeitstrahl"
|
||||
class="my-2 inline-flex items-center px-3 font-sans text-xs font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-focus-ring
|
||||
{page.url.pathname.startsWith('/zeitstrahl')
|
||||
? 'border-b-2 border-accent text-white'
|
||||
: 'text-white/70 hover:text-white'}"
|
||||
>
|
||||
{m.nav_zeitstrahl()}
|
||||
</a>
|
||||
{#if isAdmin}
|
||||
<a
|
||||
href="/admin"
|
||||
@@ -200,16 +190,6 @@ function handleOverlayKeydown(event: KeyboardEvent) {
|
||||
{m.nav_geschichten()}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/zeitstrahl"
|
||||
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
|
||||
{page.url.pathname.startsWith('/zeitstrahl')
|
||||
? 'bg-accent-bg text-ink'
|
||||
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
>
|
||||
{m.nav_zeitstrahl()}
|
||||
</a>
|
||||
|
||||
{#if isAdmin}
|
||||
<a
|
||||
href="/admin"
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
||||
import { getErrorMessage } from '$lib/shared/errors';
|
||||
|
||||
// Global timeline: personId is undefined, so no query params (REQ-001). SSR-first
|
||||
// via createApiClient so the session cookie is forwarded; no client-side fetch
|
||||
// (REQ-002). The raw payload (correspondent names/titles) is PII — never logged.
|
||||
export async function load({ fetch }) {
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.GET('/api/timeline');
|
||||
|
||||
if (result.response.status === 401) throw redirect(302, '/login');
|
||||
|
||||
if (!result.response.ok) {
|
||||
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error)));
|
||||
}
|
||||
|
||||
return { timeline: result.data! };
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import TimelineView from '$lib/timeline/TimelineView.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.timeline_heading()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-5xl px-4 py-8">
|
||||
<h1 class="mb-8 font-serif text-2xl font-bold text-brand-navy">{m.timeline_heading()}</h1>
|
||||
<TimelineView timeline={data.timeline} />
|
||||
</div>
|
||||
@@ -1,107 +0,0 @@
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
||||
import { getErrorMessage } from '$lib/shared/errors';
|
||||
import { requireWriteAll } from '$lib/shared/server/permissions';
|
||||
import {
|
||||
parseEventForm,
|
||||
validateEventForm,
|
||||
preservedFormFields,
|
||||
lookupSelections,
|
||||
toEventRequest,
|
||||
resolveNavTarget
|
||||
} from '$lib/timeline/eventFormServer';
|
||||
|
||||
export async function load({
|
||||
locals,
|
||||
params,
|
||||
url,
|
||||
fetch
|
||||
}: {
|
||||
locals: App.Locals;
|
||||
params: { id: string };
|
||||
url: URL;
|
||||
fetch: typeof globalThis.fetch;
|
||||
}) {
|
||||
requireWriteAll(locals);
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.GET('/api/timeline/events/{id}', {
|
||||
params: { path: { id: params.id } }
|
||||
});
|
||||
|
||||
// Fail closed: derived person-events (Geburt/Tod/Heirat) are not persisted and
|
||||
// have no UUID, so the API 404s for them. Any non-ok response → 404; never
|
||||
// render a blank editable form that silently POSTs a new event.
|
||||
if (!result.response.ok) throw error(404, 'Not found');
|
||||
|
||||
return { event: result.data!, originPersonId: url.searchParams.get('personId') ?? '' };
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
save: async ({
|
||||
request,
|
||||
params,
|
||||
fetch
|
||||
}: {
|
||||
request: Request;
|
||||
params: { id: string };
|
||||
fetch: typeof globalThis.fetch;
|
||||
}) => {
|
||||
const formData = await request.formData();
|
||||
const parsed = parseEventForm(formData);
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
const errors = validateEventForm(parsed);
|
||||
if (errors) {
|
||||
const { persons, documents } = await lookupSelections(
|
||||
api,
|
||||
parsed.personIds,
|
||||
parsed.documentIds
|
||||
);
|
||||
return fail(400, { ...errors, ...preservedFormFields(parsed), persons, documents });
|
||||
}
|
||||
|
||||
const versionRaw = formData.get('version')?.toString();
|
||||
const version = versionRaw ? Number(versionRaw) : undefined;
|
||||
|
||||
const result = await api.PUT('/api/timeline/events/{id}', {
|
||||
params: { path: { id: params.id } },
|
||||
body: toEventRequest(parsed, version)
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
return fail(result.response.status, {
|
||||
error: getErrorMessage(extractErrorCode(result.error)),
|
||||
...preservedFormFields(parsed)
|
||||
});
|
||||
}
|
||||
|
||||
throw redirect(303, resolveNavTarget(parsed.originPersonId));
|
||||
},
|
||||
|
||||
delete: async ({
|
||||
request,
|
||||
params,
|
||||
fetch
|
||||
}: {
|
||||
request: Request;
|
||||
params: { id: string };
|
||||
fetch: typeof globalThis.fetch;
|
||||
}) => {
|
||||
const formData = await request.formData();
|
||||
const originPersonId = formData.get('originPersonId')?.toString() ?? '';
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.DELETE('/api/timeline/events/{id}', {
|
||||
params: { path: { id: params.id } }
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
return fail(result.response.status, {
|
||||
error: getErrorMessage(extractErrorCode(result.error))
|
||||
});
|
||||
}
|
||||
|
||||
throw redirect(303, resolveNavTarget(originPersonId));
|
||||
}
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
<script lang="ts">
|
||||
import EventForm from '$lib/timeline/EventForm.svelte';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
</script>
|
||||
|
||||
<EventForm event={data.event} originPersonId={data.originPersonId} form={form} />
|
||||
@@ -1,158 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$lib/shared/api.server', () => ({
|
||||
createApiClient: vi.fn(),
|
||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
||||
}));
|
||||
|
||||
import { createApiClient } from '$lib/shared/api.server';
|
||||
import { load, actions } from './+page.server';
|
||||
|
||||
const mockFetch = vi.fn() as unknown as typeof fetch;
|
||||
|
||||
// fail() returns a union type that TS won't narrow; read its data loosely.
|
||||
const failData = (r: unknown) => (r as { data: Record<string, unknown> }).data;
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function localsWith(perms: string[] | null) {
|
||||
if (perms === null) return { user: null };
|
||||
return { user: { groups: [{ permissions: perms }] } };
|
||||
}
|
||||
|
||||
function loadEvent(perms: string[] | null, id = 'e1') {
|
||||
const url = new URL(`http://localhost/zeitstrahl/events/${id}/edit`);
|
||||
return {
|
||||
locals: localsWith(perms),
|
||||
url,
|
||||
params: { id },
|
||||
fetch: mockFetch,
|
||||
request: new Request(url),
|
||||
route: { id: '/zeitstrahl/events/[id]/edit' }
|
||||
} as never;
|
||||
}
|
||||
|
||||
function actionEvent(
|
||||
method: 'save' | 'delete',
|
||||
fields: Record<string, string | string[]>,
|
||||
id = 'e1'
|
||||
) {
|
||||
const fd = new FormData();
|
||||
for (const [k, v] of Object.entries(fields)) {
|
||||
if (Array.isArray(v)) v.forEach((x) => fd.append(k, x));
|
||||
else fd.set(k, v);
|
||||
}
|
||||
return {
|
||||
request: new Request(`http://localhost/zeitstrahl/events/${id}/edit?/${method}`, {
|
||||
method: 'POST',
|
||||
body: fd
|
||||
}),
|
||||
params: { id },
|
||||
fetch: mockFetch,
|
||||
route: { id: '/zeitstrahl/events/[id]/edit' }
|
||||
} as never;
|
||||
}
|
||||
|
||||
const EVENT_VIEW = {
|
||||
id: 'e1',
|
||||
title: 'Umzug',
|
||||
type: 'PERSONAL',
|
||||
eventDate: '1925-04-01',
|
||||
precision: 'DAY',
|
||||
version: 2,
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedBy: 'u1',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
persons: [],
|
||||
documents: []
|
||||
};
|
||||
|
||||
describe('zeitstrahl/events/[id]/edit load — gating (REQ-002/003)', () => {
|
||||
it('throws 403 for an unauthenticated (null) user', async () => {
|
||||
await expect(load(loadEvent(null))).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('throws 403 for a user without WRITE_ALL', async () => {
|
||||
await expect(load(loadEvent(['READ_ALL']))).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('zeitstrahl/events/[id]/edit load — fail closed (REQ-012)', () => {
|
||||
it('throws 404 when the GET is not ok (unknown or derived id)', async () => {
|
||||
vi.mocked(createApiClient).mockReturnValue({
|
||||
GET: vi.fn().mockResolvedValue({ response: { ok: false, status: 404 }, data: undefined })
|
||||
} as never);
|
||||
await expect(load(loadEvent(['WRITE_ALL']))).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
it('seeds the form with the event on an ok GET', async () => {
|
||||
vi.mocked(createApiClient).mockReturnValue({
|
||||
GET: vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: EVENT_VIEW })
|
||||
} as never);
|
||||
const result = await load(loadEvent(['WRITE_ALL']));
|
||||
expect(result.event).toMatchObject({ id: 'e1', title: 'Umzug' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('zeitstrahl/events/[id]/edit save action (REQ-005/013)', () => {
|
||||
it('updates via PUT (with version) and redirects on success', async () => {
|
||||
const put = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ response: { ok: true, status: 200 }, data: EVENT_VIEW });
|
||||
vi.mocked(createApiClient).mockReturnValue({ PUT: put } as never);
|
||||
|
||||
await expect(
|
||||
actions.save(
|
||||
actionEvent('save', {
|
||||
title: 'Umzug II',
|
||||
type: 'PERSONAL',
|
||||
eventDate: '1925-04-01',
|
||||
version: '2'
|
||||
})
|
||||
)
|
||||
).rejects.toMatchObject({ status: 303, location: '/zeitstrahl' });
|
||||
|
||||
expect(put).toHaveBeenCalledTimes(1);
|
||||
expect(put.mock.calls[0][1].params.path.id).toBe('e1');
|
||||
expect(put.mock.calls[0][1].body).toMatchObject({ title: 'Umzug II', version: 2 });
|
||||
});
|
||||
|
||||
it('maps a 409 conflict and does not redirect', async () => {
|
||||
const put = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ response: { ok: false, status: 409 }, error: { code: 'CONFLICT' } });
|
||||
vi.mocked(createApiClient).mockReturnValue({ PUT: put } as never);
|
||||
|
||||
const result = await actions.save(
|
||||
actionEvent('save', { title: 'Umzug', type: 'PERSONAL', eventDate: '1925-04-01' })
|
||||
);
|
||||
expect(result).toMatchObject({ status: 409 });
|
||||
expect(failData(result).error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('zeitstrahl/events/[id]/edit delete action (REQ-006/007)', () => {
|
||||
const validUuid = '22222222-2222-2222-2222-222222222222';
|
||||
|
||||
it('deletes via DELETE and redirects to the resolved target on success', async () => {
|
||||
const del = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 } });
|
||||
vi.mocked(createApiClient).mockReturnValue({ DELETE: del } as never);
|
||||
|
||||
await expect(
|
||||
actions.delete(actionEvent('delete', { originPersonId: validUuid }))
|
||||
).rejects.toMatchObject({ status: 303, location: `/persons/${validUuid}` });
|
||||
expect(del.mock.calls[0][1].params.path.id).toBe('e1');
|
||||
});
|
||||
|
||||
it('returns fail(status) and does not redirect when DELETE is not ok', async () => {
|
||||
const del = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ response: { ok: false, status: 500 }, error: { code: 'INTERNAL' } });
|
||||
vi.mocked(createApiClient).mockReturnValue({ DELETE: del } as never);
|
||||
|
||||
const result = await actions.delete(actionEvent('delete', {}));
|
||||
expect(result).toMatchObject({ status: 500 });
|
||||
expect(failData(result).error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
||||
import { getErrorMessage } from '$lib/shared/errors';
|
||||
import { requireWriteAll } from '$lib/shared/server/permissions';
|
||||
import {
|
||||
parseEventForm,
|
||||
validateEventForm,
|
||||
preservedFormFields,
|
||||
lookupSelections,
|
||||
toEventRequest,
|
||||
resolveNavTarget
|
||||
} from '$lib/timeline/eventFormServer';
|
||||
import type { PersonOption } from '$lib/person/personOption';
|
||||
import type { DocumentOption } from '$lib/document/documentTypeahead';
|
||||
|
||||
export async function load({
|
||||
locals,
|
||||
url,
|
||||
fetch
|
||||
}: {
|
||||
locals: App.Locals;
|
||||
url: URL;
|
||||
fetch: typeof globalThis.fetch;
|
||||
}) {
|
||||
requireWriteAll(locals);
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
const personId = url.searchParams.get('personId');
|
||||
const documentId = url.searchParams.get('documentId');
|
||||
|
||||
const [personResult, documentResult] = await Promise.all([
|
||||
personId ? api.GET('/api/persons/{id}', { params: { path: { id: personId } } }) : null,
|
||||
documentId ? api.GET('/api/documents/{id}', { params: { path: { id: documentId } } }) : null
|
||||
]);
|
||||
|
||||
// Silently ignore 404/403 on prefill lookups to avoid leaking entity existence.
|
||||
const initialPersons: PersonOption[] =
|
||||
personResult && personResult.response.ok && personResult.data ? [personResult.data] : [];
|
||||
const initialDocuments: DocumentOption[] =
|
||||
documentResult && documentResult.response.ok && documentResult.data
|
||||
? [
|
||||
{
|
||||
id: documentResult.data.id,
|
||||
title: documentResult.data.title,
|
||||
documentDate: documentResult.data.documentDate,
|
||||
metaDatePrecision: documentResult.data.metaDatePrecision,
|
||||
metaDateEnd: documentResult.data.metaDateEnd
|
||||
}
|
||||
]
|
||||
: [];
|
||||
|
||||
return { initialPersons, initialDocuments, originPersonId: personId ?? '' };
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
save: async ({ request, fetch }: { request: Request; fetch: typeof globalThis.fetch }) => {
|
||||
const parsed = parseEventForm(await request.formData());
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
const errors = validateEventForm(parsed);
|
||||
if (errors) {
|
||||
const { persons, documents } = await lookupSelections(
|
||||
api,
|
||||
parsed.personIds,
|
||||
parsed.documentIds
|
||||
);
|
||||
return fail(400, { ...errors, ...preservedFormFields(parsed), persons, documents });
|
||||
}
|
||||
|
||||
const result = await api.POST('/api/timeline/events', { body: toEventRequest(parsed) });
|
||||
|
||||
if (!result.response.ok) {
|
||||
return fail(result.response.status, {
|
||||
error: getErrorMessage(extractErrorCode(result.error)),
|
||||
...preservedFormFields(parsed)
|
||||
});
|
||||
}
|
||||
|
||||
throw redirect(303, resolveNavTarget(parsed.originPersonId));
|
||||
}
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
<script lang="ts">
|
||||
import EventForm from '$lib/timeline/EventForm.svelte';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
</script>
|
||||
|
||||
<EventForm
|
||||
initialPersons={data.initialPersons}
|
||||
initialDocuments={data.initialDocuments}
|
||||
originPersonId={data.originPersonId}
|
||||
form={form}
|
||||
/>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user