Files
familienarchiv/.specify/rtm.md
Marcel 15836ea9ca
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m1s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 5m1s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 23s
SDD Gate / RTM Check (pull_request) Successful in 16s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
SDD Gate / Contract Validate (pull_request) Successful in 23s
SDD Gate / Constitution Impact (pull_request) Successful in 18s
refactor(timeline): drop the canvas outer border (REQ-001)
The page is already bg-canvas, so the frame's border was the only thing
making it visible; per review it reads cleaner without it. Keep the padded
bg-canvas surface; the timeline sits on the page without a frame line.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 11:39:17 +02:00

34 KiB
Raw Blame History

Requirements Traceability Matrix (RTM)

Living document. One row per REQ-NNN across all in-flight and shipped features. The spec itself lives in the Gitea issue (issue-only — there is no committed spec.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 on main.

How to update

  1. When a feature's issue is approved (via /review-issue), add one row per REQ-NNN with the Issue set to the Gitea issue number and Status: Planned. Commit these rows on the feature branch (they merge with the feature's PR).
  2. As tasks land, fill Implementation File(s) + Test(s) and flip StatusIn progressDone.
  3. REQ-IDs are scoped per feature, so always read them together with the Issue column — REQ-001 for issue #142 is not REQ-001 for issue #150.
  4. The sdd-gate.yml CI job (rtm-check) warns (non-blocking, for now) when a row is missing its Issue or Test(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 UNKNOWNnull (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 19141918 with a Zeitraum aria-label | Done | | REQ-010 | RANGE with null eventDateEnd → start-year label, no span pill, no crash | #779 | zeitstrahl-global-view | frontend/src/lib/timeline/WorldBand.svelte | WorldBand.svelte.spec.ts#degrades a RANGE with no end to the start year | Done | | REQ-011 | band ≤12 letters → individual LetterCards | #779 | zeitstrahl-global-view | frontend/src/lib/timeline/YearBand.svelte | YearBand.svelte.spec.ts#renders each letter as a card, TimelineView.svelte.spec.ts | Done | | REQ-012 | band >12 letters → single YearLetterStrip with count + 12-month sparkline + expand toggle | #779 | zeitstrahl-global-view | frontend/src/lib/timeline/YearLetterStrip.svelte, timelineDensity.ts | YearLetterStrip.svelte.spec.ts, YearBand.svelte.spec.ts#renders a single strip, timelineDensity.spec.ts#isDense | Done | | REQ-013 | every dated entry renders date via timelineDateLabel; null → no chip | #779 | zeitstrahl-global-view | frontend/src/lib/timeline/LetterCard.svelte | LetterCard.svelte.spec.ts#renders the precision date exactly, #renders no date chip when timelineDateLabel returns null | Done | | REQ-014 | empty senderName/receiverName → "Unbekannt" placeholder, never a bare arrow | #779 | zeitstrahl-global-view | frontend/src/lib/timeline/LetterCard.svelte | LetterCard.svelte.spec.ts#shows "Unbekannt" for an empty sender, #empty receiver | Done | | REQ-015 | interior empty-year run → one folded GapSpan (single year if length 1) | #779 | zeitstrahl-global-view | frontend/src/lib/timeline/TimelineView.svelte, GapSpan.svelte | TimelineView.svelte.spec.ts#folds an interior run of empty years, #single empty interior year, GapSpan.svelte.spec.ts | Done | | REQ-016 | undated non-empty → final "Ohne Datum" section; empty → absent from DOM | #779 | zeitstrahl-global-view | frontend/src/lib/timeline/TimelineView.svelte | TimelineView.svelte.spec.ts#renders an "Ohne Datum" section, #omits the "Ohne Datum" section when empty | Done | | REQ-017 | years + undated both empty → timeline.empty_state message | #779 | zeitstrahl-global-view | frontend/src/lib/timeline/TimelineView.svelte | TimelineView.svelte.spec.ts#shows the empty state and no ol | Done | | REQ-018 | each layer carries a non-color redundant cue (glyph aria-hidden + sr-only label) | #779 | zeitstrahl-global-view | frontend/src/lib/timeline/eventCardConfig.ts, EventPill.svelte, WorldBand.svelte | TimelineView.svelte.spec.ts#redundant non-color cue label, EventPill.svelte.spec.ts#wraps the glyph aria-hidden, WorldBand.svelte.spec.ts#world glyph | Done | | REQ-019 | every accent meets WCAG AA in light + dark; HISTORICAL label falls back to text-ink-2 | #779 | zeitstrahl-global-view | frontend/src/lib/timeline/WorldBand.svelte (text-ink-2) | manual pre-merge contrast check (both ratios recorded in PR) | Done | | REQ-020 | LetterCard link ≥44px touch target + visible focus-visible ring | #779 | zeitstrahl-global-view | frontend/src/lib/timeline/LetterCard.svelte | LetterCard.svelte.spec.ts#has a touch target of at least 44px | Done | | REQ-021 | OCR/import text rendered via {...} escaping; no {@html} in lib/timeline/ | #779 | zeitstrahl-global-view | frontend/src/lib/timeline/* | code review + grep -r '@html' frontend/src/lib/timeline/ → zero; LetterCard.svelte.spec.ts | Done | | REQ-022 | non-ok load → error(status, mapped); 401 → redirect('/login'); never raw JSON | #779 | zeitstrahl-global-view | frontend/src/routes/zeitstrahl/+page.server.ts | zeitstrahl/page.server.test.ts#redirects to /login on 401, #404, #500, #403 | Done | | REQ-023 | LetterCard href is exactly /documents/{documentId}, no target | #779 | zeitstrahl-global-view | frontend/src/lib/timeline/LetterCard.svelte | LetterCard.svelte.spec.ts#links to exactly /documents/{documentId} with no target | Done | | REQ-024 | all user-facing strings via Paraglide keys (layer/derived labels localized per locale) | #779 | zeitstrahl-global-view | frontend/messages/{de,en,es}.json | messages.spec.ts#timeline layer/derived labels are localized per locale, Paraglide compile | Done | | REQ-025 | personId prop declared but undefined in global view; not passed to leaf cards | #779 | zeitstrahl-global-view | frontend/src/lib/timeline/TimelineView.svelte | TimelineView.svelte.spec.ts#renders all years and undated entries with personId undefined | Done | | REQ-026 | month-bucket helpers in $lib/shared/utils/monthBuckets.ts; no lib/timeline → lib/document import | #779 | zeitstrahl-global-view | frontend/src/lib/shared/utils/monthBuckets.ts | monthBuckets.spec.ts (relocated) + eslint boundary + grep lib/document → zero | Done | | REQ-027 | monthHistogram returns 12 MonthBuckets for the band year via shared fillDensityGaps | #779 | zeitstrahl-global-view | frontend/src/lib/timeline/timelineDensity.ts | timelineDensity.spec.ts#monthHistogram | Done | | REQ-001 | curator with WRITE_ALL granted access to /zeitstrahl/events/new + /[id]/edit | #781 | timeline-curator-forms | frontend/src/routes/zeitstrahl/events/new/+page.server.ts, [id]/edit/+page.server.ts | new/page.server.spec.ts#allows a curator with WRITE_ALL, [id]/edit/page.server.spec.ts#seeds the form with the event on an ok GET | Done | | REQ-002 | unauthenticated (null user) → 403 (null-user guard before groups deref) | #781 | timeline-curator-forms | frontend/src/routes/zeitstrahl/events/new/+page.server.ts, [id]/edit/+page.server.ts | new/page.server.spec.ts#throws 403 for an unauthenticated (null) user, [id]/edit/page.server.spec.ts#throws 403 for an unauthenticated (null) user | Done | | REQ-003 | authenticated without WRITE_ALL → 403 | #781 | timeline-curator-forms | frontend/src/routes/zeitstrahl/events/new/+page.server.ts (hasWriteAll) | new/page.server.spec.ts#throws 403 for an authenticated user without WRITE_ALL, [id]/edit/page.server.spec.ts#throws 403 for a user without WRITE_ALL | Done | | REQ-004 | valid create → POST + redirect to resolved target | #781 | timeline-curator-forms | frontend/src/routes/zeitstrahl/events/new/+page.server.ts (save), lib/timeline/eventFormServer.ts#toEventRequest | new/page.server.spec.ts#posts a TimelineEventRequest and redirects on success | Done | | REQ-005 | valid edit → PUT + redirect to resolved target | #781 | timeline-curator-forms | frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts (save) | [id]/edit/page.server.spec.ts#updates via PUT (with version) and redirects on success | Done | | REQ-006 | confirmed delete → DELETE + redirect | #781 | timeline-curator-forms | frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts (delete), lib/timeline/EventForm.svelte (getConfirmService) | [id]/edit/page.server.spec.ts#deletes via DELETE and redirects to the resolved target on success | Done | | REQ-007 | non-ok DELETE → surface mapped error, no redirect | #781 | timeline-curator-forms | frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts (delete) | [id]/edit/page.server.spec.ts#returns fail(status) and does not redirect when DELETE is not ok | Done | | REQ-008 | precision = RANGE → end-date field visible | #781 | timeline-curator-forms | frontend/src/lib/shared/primitives/DatePrecisionField.svelte, lib/timeline/EventForm.svelte | EventForm.svelte.spec.ts#reveals the end-date field when precision is RANGE, WhoWhenSection.svelte.spec.ts#reveals the end-date field when precision is RANGE | Done | | REQ-009 | precision ≠ RANGE → end-date hidden, eventDateEnd submitted null | #781 | timeline-curator-forms | frontend/src/lib/shared/primitives/DatePrecisionField.svelte, lib/timeline/eventFormServer.ts#parseEventForm | EventForm.svelte.spec.ts#hides the end-date field when precision is YEAR, new/page.server.spec.ts#sends eventDateEnd: null when precision is not RANGE | Done | | REQ-010 | blank title → localized required error, no nav, picker values preserved | #781 | timeline-curator-forms | frontend/src/lib/timeline/eventFormServer.ts#validateEventForm, EventForm.svelte | EventForm.svelte.spec.ts#shows a required-field error when title is blank, new/page.server.spec.ts#returns fail(400) with preserved picker arrays on blank title | Done | | REQ-011 | blank title + date → both errors via per-field aria-invalid | #781 | timeline-curator-forms | frontend/src/lib/timeline/eventFormServer.ts#validateEventForm | new/page.server.spec.ts#surfaces both title and date errors when both blank | Done | | REQ-012 | unknown/derived event id (non-ok GET) → 404, never blank create form | #781 | timeline-curator-forms | frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts (load) | [id]/edit/page.server.spec.ts#throws 404 when the GET is not ok (unknown or derived id) | Done | | REQ-013 | 409 Conflict → generic conflict message, no redirect (no merge UI) | #781 | timeline-curator-forms | frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts (save) | [id]/edit/page.server.spec.ts#maps a 409 conflict and does not redirect, new/page.server.spec.ts#maps the API error and does not redirect on a non-ok save (incl. 409) | Done | | REQ-014 | valid ?personId/?documentId prefill pre-selected; unknown id silently ignored | #781 | timeline-curator-forms | frontend/src/routes/zeitstrahl/events/new/+page.server.ts (load Promise.all), EventForm.svelte | new/page.server.spec.ts#preselects a valid person and ignores an unknown document, EventForm.svelte.spec.ts#preselects a person when initialPersons is provided | Done | | REQ-015 | absent/empty/non-UUID originPersonId → redirect /zeitstrahl (CWE-601) | #781 | timeline-curator-forms | frontend/src/lib/timeline/eventFormServer.ts#resolveNavTarget | new/page.server.spec.ts#defaults to /zeitstrahl when originPersonId is not a valid UUID, #redirects to /persons/{id} when originPersonId is a valid UUID | Done | | REQ-016 | title/description/chip labels via default {...} escaping, never {@html} (CWE-79) | #781 | timeline-curator-forms | frontend/src/lib/timeline/EventForm.svelte | code review + grep -r '@html' frontend/src/lib/timeline/ → zero | Done | | REQ-017 | labelled pickers, visible empty states, ≥44px chip remove targets | #781 | timeline-curator-forms | frontend/src/lib/person/PersonMultiSelect.svelte, document/DocumentMultiSelect.svelte, EventForm.svelte | PersonMultiSelect.svelte.spec.ts, DocumentMultiSelect.svelte.spec.ts (green post-44px fix), EventForm.svelte.spec.ts#preselects a person when initialPersons is provided | Done | | REQ-001 | /zeitstrahl wraps the timeline in a .tl-canvas surface (rounded, bg-canvas, padding; outer border dropped in review — page is already bg-canvas) | #833 | zeitstrahl-visual-fidelity | frontend/src/routes/zeitstrahl/+page.svelte | routes/zeitstrahl/page.svelte.spec.ts#wraps the timeline in a padded canvas surface, without an outer border | Done | | REQ-002 | meta sub-line: range + letter count + event count (years + undated) + "Gruppierung: Datum"; range/line omitted when empty | #833 | zeitstrahl-visual-fidelity | frontend/src/lib/timeline/timelineMeta.ts, frontend/src/routes/zeitstrahl/+page.svelte | timelineMeta.spec.ts (4 cases), routes/zeitstrahl/page.svelte.spec.ts#renders the meta sub-line, #omits the range segment, #omits the entire sub-line | Done | | REQ-003 | year badge centered on axis ≥1024px, left spine <1024px; sticky top:4rem preserved | #833 | zeitstrahl-visual-fidelity | frontend/src/lib/timeline/YearBand.svelte | YearBand.svelte.spec.ts#centers the year badge on the axis at desktop, #left-aligns the year badge at phone width, #keeps the sticky year heading at top:4rem | Done | | REQ-004 | year badge node marker on the spine, never overlapping the badge text (desktop + phone) | #833 | zeitstrahl-visual-fidelity | frontend/src/lib/timeline/YearBand.svelte | YearBand.svelte.spec.ts#renders a year-badge node marker that clears the badge text on phone | Done | | REQ-005 | per-letter connector dot (white fill, mint ring) on the spine; phone column indented clear of card | #833 | zeitstrahl-visual-fidelity | frontend/src/lib/timeline/YearBand.svelte | YearBand.svelte.spec.ts#renders one connector dot per letter row, each clearing its card on phone | Done | | REQ-006 | axis gradient 3-stop mint→navy→slate via --palette-mint/--palette-navy/--c-tag-slate | #833 | zeitstrahl-visual-fidelity | frontend/src/lib/timeline/TimelineView.svelte | TimelineView.svelte.spec.ts#paints the axis with a three-stop gradient (+ REQ-013 grep) | Done | | REQ-007 | EventPill subtitle {date} · {provenance} keyed off entry.derived (abgeleitet/kuratiert) | #833 | zeitstrahl-visual-fidelity | frontend/src/lib/timeline/EventPill.svelte | EventPill.svelte.spec.ts#appends the "abgeleitet" provenance, #appends the "kuratiert" provenance, #never shows persönlich/SEASON | Done | | REQ-008 | LetterCard title prefixed with aria-hidden ✉ + sr-only "Brief"; href intact | #833 | zeitstrahl-visual-fidelity | frontend/src/lib/timeline/LetterCard.svelte | LetterCard.svelte.spec.ts#prefixes a present title with an aria-hidden ✉, #renders an HTML-bearing title verbatim | Done | | REQ-009 | WorldBand inline "· historisch" descriptor (non-RANGE & RANGE); RANGE span pill + aria-label intact | #833 | zeitstrahl-visual-fidelity | frontend/src/lib/timeline/WorldBand.svelte | WorldBand.svelte.spec.ts#appends the inline "· historisch", #follows the RANGE span pill with inline "· historisch" | Done | | REQ-010 | YearLetterStrip count ✉ + sr-only label + "Monats-Dichte" caption; expand toggle preserved | #833 | zeitstrahl-visual-fidelity | frontend/src/lib/timeline/YearLetterStrip.svelte | YearLetterStrip.svelte.spec.ts#prefixes the count with an aria-hidden ✉, #keeps the expand toggle and its label | Done | | REQ-011 | YearLetterStrip exactly two endpoint month-axis labels (Jan/Dez {year}) ≥10px via formatTickLabel | #833 | zeitstrahl-visual-fidelity | frontend/src/lib/timeline/YearLetterStrip.svelte | YearLetterStrip.svelte.spec.ts#renders exactly two endpoint month-axis labels | Done | | REQ-012 | undated "Ohne Datum · {count}" in a dashed frame; empty → absent; kind/type dispatch preserved | #833 | zeitstrahl-visual-fidelity | frontend/src/lib/timeline/TimelineView.svelte | TimelineView.svelte.spec.ts#frames the undated section with a dashed border and shows the count, #omits the "Ohne Datum" section when empty | Done | | REQ-013 | all new styles use semantic tokens; corrected hex grep returns zero hits | #833 | zeitstrahl-visual-fidelity | frontend/src/lib/timeline/* | grep gate (REQ-013 form) → zero | Done | | REQ-014 | no change to DTO order, density threshold (12), gap-folding, or ol/section/h2 structure | #833 | zeitstrahl-visual-fidelity | frontend/src/lib/timeline/* | existing timeline + zeitstrahl/page.server.test.ts suites stay green (142 tests) | Done | | REQ-015 | new user-facing strings are Paraglide keys present in de/en/es with matching key sets | #833 | zeitstrahl-visual-fidelity | frontend/messages/{de,en,es}.json | messages.spec.ts#zeitstrahl visual-fidelity keys are present in all locales, #identical key sets | Done | | REQ-016 | LetterCard with no title → no ✉, no sr-only "Brief"; sender→receiver + date still render | #833 | zeitstrahl-visual-fidelity | frontend/src/lib/timeline/LetterCard.svelte | LetterCard.svelte.spec.ts#renders no ✉ glyph and no "Brief" label when the title is empty | Done |