- ADR-044 extends ADR-039 to the relationship edge: LocalDate+DatePrecision, update re-validation of create invariants, no @Version (last-write-wins), DELETE→404 anti-enumeration alignment, precise derived marriage date, and the relationshipDates.ts location reusing the existing person→shared boundary. - db-orm.puml: person_relationships now carries from_date/from_date_precision/ to_date/to_date_precision; db-relationships.puml gets a V78 columns-only note. - DEPLOYMENT.md §5: V78 deploy note — no maintenance window, stop-old-then-start ordering (not rolling-deploy-safe), targeted pg_restore rollback. - CLAUDE.md error-code list gains INVALID_RELATIONSHIP_DATES. - rtm.md: REQ-001..REQ-019 for #837 mapped to impl + tests, all Done. Refs #837 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
40 KiB
Requirements Traceability Matrix (RTM)
Living document. One row per
REQ-NNNacross all in-flight and shipped features. The spec itself lives in the Gitea issue (issue-only — there is no committedspec.md); this matrix is the part of the spec that is committed: it links each requirement to its issue, the code that implements it, and the test(s) that prove it — so any requirement traces end to end, and any orphan (a requirement with no test) is visible onmain.
How to update
- When a feature's issue is approved (via
/review-issue), add one row perREQ-NNNwith theIssueset to the Gitea issue number andStatus: Planned. Commit these rows on the feature branch (they merge with the feature's PR). - As tasks land, fill
Implementation File(s)+Test(s)and flipStatus→In progress→Done. REQ-IDs are scoped per feature, so always read them together with theIssuecolumn —REQ-001for issue #142 is notREQ-001for issue #150.- The
sdd-gate.ymlCI job (rtm-check) warns (non-blocking, for now) when a row is missing itsIssueorTest(s). It flips to blocking once adoption settles (see the workflow's TODO).
Status legend
Planned · In progress · Done · Deferred
Matrix
| REQ-ID | Requirement Summary | Issue | Feature | Implementation File(s) | Test(s) | Status |
|---|---|---|---|---|---|---|
| REQ-001 | Store avatar at avatars/{userId}, overwrite |
#example | profile-picture-upload (_example) | UserService (planned) |
UserServiceAvatarTest#storesUnderUserKey, #replaceLeavesNoOrphan |
Planned |
| REQ-002 | Upload self avatar → 200 + avatarUrl | #example | profile-picture-upload (_example) | UserAvatarController, UserService (planned) |
UserAvatarControllerTest#uploadReturnsAvatarUrl |
Planned |
| REQ-003 | Delete self avatar → avatarUrl null | #example | profile-picture-upload (_example) | UserAvatarController (planned) |
UserAvatarControllerTest#deleteClearsAvatar |
Planned |
| REQ-004 | No avatar → null + initials placeholder | #example | profile-picture-upload (_example) | UserProfileView, avatar component (planned) |
avatar-placeholder.svelte.spec.ts |
Planned |
| REQ-005 | ADMIN_USER may delete others' avatar | #example | profile-picture-upload (_example) | UserAvatarController (planned) |
UserAvatarControllerTest#adminDeletesOthersAvatar |
Planned |
| REQ-006 | Unauthenticated → 401, store nothing | #example | profile-picture-upload (_example) | SecurityConfig, controller (planned) |
UserAvatarControllerTest#unauthenticatedReturns401 |
Planned |
| REQ-007 | Non-image → 400 UNSUPPORTED_FILE_TYPE | #example | profile-picture-upload (_example) | UserService (planned) |
UserAvatarControllerTest#rejectsNonImage |
Planned |
| REQ-008 | Over 2 MB → 400 AVATAR_TOO_LARGE | #example | profile-picture-upload (_example) | UserService, ErrorCode (planned) |
UserAvatarControllerTest#rejectsOversize |
Planned |
| REQ-009 | Non-admin on others → 403 FORBIDDEN | #example | profile-picture-upload (_example) | UserAvatarController (planned) |
UserAvatarControllerTest#nonAdminForbiddenOnOthers |
Planned |
| REQ-001 | Render dated entry via shared formatDocumentDate (de/en/es) |
#778 | timeline-date-label | frontend/src/lib/timeline/dateLabel.ts |
dateLabel.spec.ts › renders a DAY date localized in German, renders a SEASON date with the German season word, delegates a same-year RANGE to formatDocumentDate |
Done |
| REQ-002 | Non-UNKNOWN + non-empty date → shared label, raw=null, getLocale() |
#778 | timeline-date-label | frontend/src/lib/timeline/dateLabel.ts |
dateLabel.spec.ts › renders a DAY date localized in German, renders a DAY date localized in English |
Done |
| REQ-003 | UNKNOWN → null (no chip) |
#778 | timeline-date-label | frontend/src/lib/timeline/dateLabel.ts |
dateLabel.spec.ts › returns null for UNKNOWN precision even with a date, returns null for UNKNOWN precision without a date |
Done |
| REQ-004 | null/undefined/'' eventDate → null, no formatter call |
#778 | timeline-date-label | frontend/src/lib/timeline/dateLabel.ts |
dateLabel.spec.ts › returns null for APPROX with a null eventDate, without calling the formatter, returns null for DAY with an empty-string eventDate, treats undefined eventDateEnd identically to null for RANGE |
Done |
| REQ-005 | No rendering logic outside documentDate.ts |
#778 | timeline-date-label | frontend/src/lib/timeline/dateLabel.ts (façade) |
dateLabel.spec.ts › delegates a same-year RANGE to formatDocumentDate (asserts byte-identical delegation) |
Done |
| 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 |
| 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 |
| REQ-001 | /zeitstrahl wraps the timeline in a .tl-canvas surface (rounded, bg-canvas, padding; outer border dropped in review — page is already bg-canvas) | #833 | zeitstrahl-visual-fidelity | frontend/src/routes/zeitstrahl/+page.svelte | routes/zeitstrahl/page.svelte.spec.ts#wraps the timeline in a padded canvas surface, without an outer border | Done |
| REQ-002 | meta sub-line: range + letter count + event count (years + undated) + "Gruppierung: Datum"; range/line omitted when empty | #833 | zeitstrahl-visual-fidelity | frontend/src/lib/timeline/timelineMeta.ts, frontend/src/routes/zeitstrahl/+page.svelte | timelineMeta.spec.ts (4 cases), routes/zeitstrahl/page.svelte.spec.ts#renders the meta sub-line, #omits the range segment, #omits the entire sub-line | Done |
| REQ-003 | year badge centered on axis ≥1024px, left spine <1024px; sticky top:4rem preserved | #833 | zeitstrahl-visual-fidelity | frontend/src/lib/timeline/YearBand.svelte | YearBand.svelte.spec.ts#centers the year badge on the axis at desktop, #left-aligns the year badge at phone width, #keeps the sticky year heading at top:4rem | Done |
| REQ-004 | year badge node marker on the spine, never overlapping the badge text (desktop + phone) | #833 | zeitstrahl-visual-fidelity | frontend/src/lib/timeline/YearBand.svelte | YearBand.svelte.spec.ts#renders a year-badge node marker that clears the badge text on phone | Done |
| REQ-005 | per-letter connector dot (white fill, mint ring) on the spine; phone column indented clear of card | #833 | zeitstrahl-visual-fidelity | frontend/src/lib/timeline/YearBand.svelte | YearBand.svelte.spec.ts#renders one connector dot per letter row, each clearing its card on phone | Done |
| REQ-006 | axis gradient 3-stop mint→navy→slate via --palette-mint/--palette-navy/--c-tag-slate | #833 | zeitstrahl-visual-fidelity | frontend/src/lib/timeline/TimelineView.svelte | TimelineView.svelte.spec.ts#paints the axis with a three-stop gradient (+ REQ-013 grep) | Done |
| REQ-007 | EventPill subtitle {date} · {provenance} keyed off entry.derived (abgeleitet/kuratiert) | #833 | zeitstrahl-visual-fidelity | frontend/src/lib/timeline/EventPill.svelte | EventPill.svelte.spec.ts#appends the "abgeleitet" provenance, #appends the "kuratiert" provenance, #never shows persönlich/SEASON | Done |
| REQ-008 | LetterCard title prefixed with aria-hidden ✉ + sr-only "Brief"; href intact | #833 | zeitstrahl-visual-fidelity | frontend/src/lib/timeline/LetterCard.svelte | LetterCard.svelte.spec.ts#prefixes a present title with an aria-hidden ✉, #renders an HTML-bearing title verbatim | Done |
| REQ-009 | WorldBand inline "· historisch" descriptor (non-RANGE & RANGE); RANGE span pill + aria-label intact | #833 | zeitstrahl-visual-fidelity | frontend/src/lib/timeline/WorldBand.svelte | WorldBand.svelte.spec.ts#appends the inline "· historisch", #follows the RANGE span pill with inline "· historisch" | Done |
| REQ-010 | YearLetterStrip count ✉ + sr-only label + "Monats-Dichte" caption; expand toggle preserved | #833 | zeitstrahl-visual-fidelity | frontend/src/lib/timeline/YearLetterStrip.svelte | YearLetterStrip.svelte.spec.ts#prefixes the count with an aria-hidden ✉, #keeps the expand toggle and its label | Done |
| REQ-011 | YearLetterStrip exactly two endpoint month-axis labels (Jan/Dez {year}) ≥10px via formatTickLabel | #833 | zeitstrahl-visual-fidelity | frontend/src/lib/timeline/YearLetterStrip.svelte | YearLetterStrip.svelte.spec.ts#renders exactly two endpoint month-axis labels | Done |
| REQ-012 | undated "Ohne Datum · {count}" in a dashed frame; empty → absent; kind/type dispatch preserved | #833 | zeitstrahl-visual-fidelity | frontend/src/lib/timeline/TimelineView.svelte | TimelineView.svelte.spec.ts#frames the undated section with a dashed border and shows the count, #omits the "Ohne Datum" section when empty | Done |
| REQ-013 | all new styles use semantic tokens; corrected hex grep returns zero hits | #833 | zeitstrahl-visual-fidelity | frontend/src/lib/timeline/* | grep gate (REQ-013 form) → zero | Done |
| REQ-014 | no change to DTO order, density threshold (12), gap-folding, or ol/section/h2 structure | #833 | zeitstrahl-visual-fidelity | frontend/src/lib/timeline/* | existing timeline + zeitstrahl/page.server.test.ts suites stay green (142 tests) | Done |
| REQ-015 | new user-facing strings are Paraglide keys present in de/en/es with matching key sets | #833 | zeitstrahl-visual-fidelity | frontend/messages/{de,en,es}.json | messages.spec.ts#zeitstrahl visual-fidelity keys are present in all locales, #identical key sets | Done |
| REQ-016 | LetterCard with no title → no ✉, no sr-only "Brief"; sender→receiver + date still render | #833 | zeitstrahl-visual-fidelity | frontend/src/lib/timeline/LetterCard.svelte | LetterCard.svelte.spec.ts#renders no ✉ glyph and no "Brief" label when the title is empty | Done |
| REQ-001 | store relationship from/to as nullable LocalDate + NOT-NULL DatePrecision (default UNKNOWN) | #837 | relationship-edit-dates | person/relationship/PersonRelationship.java, V78__relationship_years_to_localdate.sql | RelationshipMigrationTest#yearColumnsDropped_andNamedCheckConstraintsExist, RelationshipServiceTest#addRelationship_persists_with_storage_truth | Done |
| REQ-002 | V78 backfills non-null years as {year}-01-01/YEAR, nulls → null/UNKNOWN, rows preserved | #837 | relationship-edit-dates | V78__relationship_years_to_localdate.sql | RelationshipMigrationTest#backfill_fromYearAndToYear_becomeYearPrecisionDates, #backfill_bothNull_leavesDatesNullAndPrecisionsUnknown, #backfill_preservesRowCount | Done |
| REQ-003 | named DB CHECKs: coherence both ends + fromDate ≤ toDate | #837 | relationship-edit-dates | V78__relationship_years_to_localdate.sql | RelationshipMigrationTest#orderCheckConstraint_rejectsToDateBeforeFromDate, #coherenceCheckConstraint_rejectsDatePresentWithUnknownPrecision | Done |
| REQ-004 | PUT updates the relationship → 200 RelationshipDTO | #837 | relationship-edit-dates | person/relationship/RelationshipController#updateRelationship, RelationshipService#updateRelationship | RelationshipControllerTest#updateRelationship_returns200_with_RelationshipDTO_for_WRITE_ALL_user, RelationshipServiceIntegrationTest#updateRelationship_persists_new_type_dates_and_notes, page.server.spec.ts#updateRelationship PUTs to the relId path with the new body | Done |
| REQ-005 | create + update rejected with 403 without WRITE_ALL | #837 | relationship-edit-dates | person/relationship/RelationshipController (@RequirePermission) | RelationshipControllerTest#updateRelationship_returns403_for_READ_ALL_only_user, #addRelationship_returns403_for_user_with_READ_ALL_only | Done |
| REQ-006 | relId not existing / not owned by person → 404 RELATIONSHIP_NOT_FOUND | #837 | relationship-edit-dates | person/relationship/RelationshipService#loadOwnedRelationship | RelationshipServiceTest#updateRelationship_throws_NOT_FOUND_when_rel_belongs_to_different_person, RelationshipServiceIntegrationTest#updateRelationship_throws_404_when_rel_belongs_to_different_person | Done |
| REQ-007 | update with relatedPersonId == {id} → 400 VALIDATION_ERROR | #837 | relationship-edit-dates | person/relationship/RelationshipService#updateRelationship | RelationshipServiceTest#updateRelationship_throws_VALIDATION_ERROR_on_self_relation | Done |
| REQ-008 | resulting (person, relatedPerson, type) duplicate → 409 DUPLICATE_RELATIONSHIP | #837 | relationship-edit-dates | person/relationship/RelationshipService#updateRelationship | RelationshipServiceTest#updateRelationship_throws_DUPLICATE_when_db_constraint_violated | Done |
| REQ-009 | update to PARENT_OF with reverse PARENT_OF present → 409 CIRCULAR_RELATIONSHIP | #837 | relationship-edit-dates | person/relationship/RelationshipService#updateRelationship | RelationshipServiceTest#updateRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists | Done |
| REQ-010 | toDate before fromDate → 400 INVALID_RELATIONSHIP_DATES | #837 | relationship-edit-dates | person/relationship/RelationshipService#validateRelationshipDates, exception/ErrorCode, frontend/src/lib/shared/errors.ts | RelationshipServiceTest#addRelationship_throws_INVALID_RELATIONSHIP_DATES_when_toDate_before_fromDate, #updateRelationship_throws_INVALID_RELATIONSHIP_DATES_when_toDate_before_fromDate | Done |
| REQ-011 | date+UNKNOWN precision, or precision without date → 400 INVALID_DATE_PRECISION | #837 | relationship-edit-dates | person/relationship/RelationshipService#requireDatePrecisionCoherence | RelationshipServiceTest#addRelationship_throws_INVALID_DATE_PRECISION_when_date_present_but_precision_unknown, #addRelationship_throws_INVALID_DATE_PRECISION_when_precision_set_without_date | Done |
| REQ-012 | invalid enum / missing relatedPersonId·relationType / notes > 2000 → 400 VALIDATION_ERROR | #837 | relationship-edit-dates | person/relationship/RelationshipUpsertRequest (Bean Validation), RelationshipController | RelationshipControllerTest#updateRelationship_returns400_when_relationType_is_unknown_value, #addRelationship_returns400_when_relationType_is_unknown_value | Done |
| REQ-013 | updating into a family type flags both endpoints (additive) | #837 | relationship-edit-dates | person/relationship/RelationshipService#updateRelationship | RelationshipServiceTest#updateRelationship_marks_both_endpoints_family_when_updated_to_family_type | Done |
| REQ-014 | persist + display notes on create, update, read and edit views | #837 | relationship-edit-dates | person/relationship/RelationshipService, frontend/.../AddRelationshipForm.svelte, routes/persons/[id]/PersonRelationshipsCard.svelte | RelationshipServiceIntegrationTest#updateRelationship_persists_new_type_dates_and_notes, AddRelationshipForm.svelte.spec.ts#round-trips the notes into the textarea, PersonRelationshipsCard.svelte.test.ts#shows the notes line | Done |
| REQ-015 | detail view shows the date range at its precision; no dates → no date line | #837 | relationship-edit-dates | frontend/src/lib/person/relationshipDates.ts, routes/persons/[id]/PersonRelationshipsCard.svelte | relationshipDates.spec.ts, PersonRelationshipsCard.svelte.test.ts#renders the date range at its stored precision, #renders no date line when the relationship has no dates | Done |
| REQ-016 | edit affordance opens a form pre-filled with type/person/dates+precision/notes; precision DAY/MONTH/YEAR | #837 | relationship-edit-dates | frontend/.../AddRelationshipForm.svelte, RelationshipDateField.svelte, RelationshipChip.svelte | AddRelationshipForm.svelte.spec.ts#pre-fills the from-date as dd.mm.yyyy, #offers only DAY/MONTH/YEAR in each precision select, RelationshipChip.svelte.spec.ts#shows an Edit affordance with an accessible name when canWrite and onEdit | Done |
| REQ-017 | derived Heirat sources SPOUSE_OF.fromDate + fromDatePrecision | #837 | relationship-edit-dates | timeline/TimelineEventService#buildMarriageEvents | DerivedEventsAssemblyTest#should_emit_day_precision_heirat_from_spouse_fromDate | Done |
| REQ-018 | unauthenticated PUT → 401, no row modified | #837 | relationship-edit-dates | person/relationship/RelationshipController (SecurityConfig) | RelationshipControllerTest#updateRelationship_returns401_whenUnauthenticated_and_does_not_touch_service | Done |
| REQ-019 | while a create/update request is in flight, submit is disabled + shows a progress indicator | #837 | relationship-edit-dates | frontend/src/lib/person/relationship/AddRelationshipForm.svelte | AddRelationshipForm.svelte.spec.ts#disables the submit and shows a progress spinner while a submit is in flight | Done |