Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
23 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 |