Timeline: assembly endpoint GET /api/timeline #777
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Milestone: Zeitstrahl — Family Timeline
Spec:
docs/superpowers/specs/2026-06-07-family-timeline-design.md§ "Assembly & API"Depends on: TimelineEvent entity (issue #2), derived person-events (issue #4 — this endpoint consumes an already-tested
deriveEvents(...); it does not re-implement Geburt/Tod/Heirat logic).Context
The read endpoint that merges all three layers (curated events + derived person-events + letters) into a year-bucketed structure for the frontend. This is step 5 of the 11-issue Zeitstrahl breakdown and the most coupling-heavy backend slice:
TimelineServicereads its ownTimelineEventRepositoryplus three other domains (Person, Document, PersonRelationship) — always via their services, not repositories.Scope
GET /api/timeline— gated by@RequirePermission(Permission.READ_ALL). Optional params:personId(UUID),generation(int,@Min(0)),type(PERSONAL/HISTORICAL),fromYear(int),toYear(int).?personId=…— no separate endpoint.ErrorCode, no new Flyway migration, no new infrastructure/Docker/env/config.Architecture & boundaries
DocumentService.getAllForTimeline(): List<Document>(global path — bulk-fetch in a single query, avoids N+1 fan-out);DocumentService.getDocumentsBySender(UUID)+getDocumentsByReceiver(UUID)(personId path);PersonService.getById(UUID)/getAllById(...)/getPersonsByGeneration(int)(new method — see § New service methods); and relationship data viaRelationshipService(notPersonRelationshipService— the class isRelationshipServiceinperson/relationship/). Never reach into another domain's repository.deriveEvents(...)output — merged, never re-derived here.READ_ALLuser already sees all documents/persons. The timeline exposing all letters/events is consistent with the existing read model — not a new data-exposure regression.personIdis a lookup key, not an authorization boundary.New service methods (in scope for this issue)
PersonService.getPersonsByGeneration(int generation): List<Person>One new method + one new repository method. Delegates to
PersonRepository.findByGeneration(int generation)(a single@Queryor derived-method on thepersonstable). Required by thegenerationfilter to resolve person UUIDs without boundary violations or O(all-persons) in-memory filtering.DocumentService.getAllForTimeline(): List<Document>One new service method for the global timeline path. Returns all documents in a single Hibernate query (no per-person fan-out). The
personIdpath continues to usegetDocumentsBySender+getDocumentsByReceiverand deduplicates.DTOs (Java
recordtypes — immutable read-projections, flat intimeline/; do NOT share with the CRUDTimelineEventRequestinput)TimelineEntryDTO:kind(EVENT|LETTER) —@Schema(requiredMode = REQUIRED)eventDate(rawLocalDate),precision(rawDatePrecisionenum exposed as string via SpringDoc — never a server-formatted label),eventDateEnd(nullable — forRANGE)title,type(EventType, null for letters),derived(boolean) —@Schema(requiredMode = REQUIRED)on all always-populated fieldseventId(null for derived entries and for letters — no@Schema(requiredMode = REQUIRED); frontend cannot wire an edit link to a non-existent event) /documentId(set for letters, norequiredMode = REQUIRED)senderName(String, never null — fallback chain:sender.getDisplayName()→document.getSenderText()→"") andreceiverName(String, never null — same fallback chain viareceiverText). The unlinked-correspondent case is historically most common; the DTO must never carry null where the frontend expects a display string.derived == true || eventId == null→ no edit link should be rendered. Both signals are present in every entry; the frontendEventCard.svelteshould check either one.TimelineYearDTO—{ year, entries[] }—@Schema(requiredMode = REQUIRED)on both fields.TimelineDTO—{ years[], undated[] }—@Schema(requiredMode = REQUIRED)on both fields.DatePrecisionas a string enum in the generated spec (it should via@Enumerated(STRING)analog). Runnpm run generate:apiafter DTOs land and confirm generated TS types are non-optional where annotated.Resolved decisions
generationfilter for letters → sender OR receiver, never duplicated. A letter matchesgeneration=Nwhen its sender OR its receiver is in generation N; if both match it still appears once. Rationale: most intuitive; does not hide received correspondence; same dedup rule as thepersonIdpath.?type=does NOT filter the letter layer.typefilters only the event layer (curated + derived); letters always pass through anytypefilter. Rationale:typeis anEventTypeproperty letters do not possess — filtering them by it is meaningless.TimelineDTOpayload, no pagination (MVP). Rationale: family-scale archive on i7-6700/64 GB VPS; the year is the rendering axis; frontend virtualizes/lazy-renders bands. If global payload becomes a latency problem, add HTTPCache-Control/ETag — not a cache layer, not Redis.type+generation+fromYear/toYear+personIdare conjunctive).fromYear > toYear→ HTTP 400 (consistent withRelationshipService.validateYearsat line 158–161).typebinds to theEventTypeenum in the controller signature (not rawString), so Spring's converter rejects unknown values at the boundary with a 400.personId→DomainException.notFound(reusePERSON_NOT_FOUND) viaPersonService.getById, not a 500 or a silent empty-200.yearsandundated(e.g. apersonIdwith zero events and zero letters). Not a 404.PersonService.getPersonsByGeneration(int)→ Option A: add to this issue's scope. One new service method + one new repository method. Clean delegation, no boundary violation, no O(all-persons) in-memory filtering. Rationale: Option B (fetch all + filter) is wasteful and fragile; Option A is one line each.DocumentService.getAllForTimeline()→ add explicitly to scope and task list. Named asgetAllForTimeline(): List<Document>. Rationale: the existinggetDocumentsBySender/getDocumentsByReceiverare per-person queries and cannot serve the global path without an N+1 fan-out.CLAUDE.mdpackage table → update alongside the C4 diagram. Both are doc tasks in this issue's scope. Rationale:CLAUDE.mdis the primary developer onboarding reference; an unlisted package misleads future contributors.personId+generationcombined → AND logic; may yield empty results. If the target person is in generation 3 andgeneration=4is passed, the result is empty. No special-case: AND composition applies strictly. Rationale: consistent with filter semantics; document explicitly.fromYear/toYearfilter → filter by start year (eventDate.getYear()). A RANGE event whose start year is outside[fromYear, toYear]is excluded even if its end year falls within range. Rationale: consistent with band placement (start-year band); simpler and unambiguous.generation=Nfilter → returns nothing. Null generation does not match any N. Rationale: null is not "in any generation"; filtering should only surface items with a confirmed match.generationparameter →@Min(0)validation. Add@RequestParam(required = false) @Min(0) Integer generationon the controller method.generation=-1returns HTTP 400. Rationale: clearly invalid values should fail fast at the boundary rather than silently returning empty results.senderName/receiverNameareString(never null) in the DTO record. Ultimate fallback is"". Rationale: the frontendLetterCard.sveltemust never receive null where it expects a display string.withinBandComparator→ namedComparatorconstant, not inlined. Defined as a package-private named constant (e.g.static final Comparator<TimelineEntryDTO> WITHIN_BAND_ORDER = ...). Rationale: independently testable; readable; not buried in a stream pipeline.Bucketing & ordering rules
Document.documentDate.getYear().undatedwhendocumentDate == nullORmetaDatePrecision == UNKNOWN. (documentDateis nullable independent of precision — guard the.getYear()NPE.)RANGEevents appear only in their start-year band (not duplicated); an open-endedRANGEwith nulleventDateEndstill places in the start-year band without crashing.fromYear/toYearfor RANGE events: filter by start year. A RANGE event outside[fromYear, toYear]on its start year is excluded — even if its end year falls within the range. Consistent with band placement.Comparatorconstant (WITHIN_BAND_ORDER): sort by full date when precision allows; tiebreak by precision granularity (DAY>MONTH>SEASON>YEAR>APPROX), then title alphabetically, then id as final tiebreak so the same payload never reorders between requests.derivedandtypethrough so the frontend can apply personal vs muted "world" accents and suppress the edit affordance on derived events. Label/German formatting (dateLabel.ts,formatTickLabel) is frontend step 6 — do not leak it into the backend DTO.Performance
DocumentService.getAllForTimeline()bulk-fetches all documents in a single query; assemble in memory. Do NOT loopgetDocumentsBySenderper person (avoid O(persons) fan-out / N+1).personIdpath: composegetDocumentsBySender(id)+getDocumentsByReceiver(id), deduping a letter where the same person is both sender and receiver.Tasks
PersonService.getPersonsByGeneration(int generation): List<Person>+PersonRepository.findByGeneration(int generation)— one method each.DocumentService.getAllForTimeline(): List<Document>— bulk single-query method for the global timeline path.TimelineService.assemble(...)orchestrator (~20 lines, reads like prose) merging curated events + derived events (from #4) + letters.bucketByYear(...)and namedWITHIN_BAND_ORDERComparatorconstant as independently testable units.documentDate.getYear()whendocumentDate != nullANDprecision != UNKNOWN; otherwiseundated.RANGEshown in start-year band only; nulleventDateEndhandled;fromYear/toYearfilter tests RANGE event's start year only.generationletter-match = sender OR receiver, deduped;typefilters events only, letters always pass;personId+generationAND logic (may yield empty).fromYear > toYear→ 400;typebound toEventTypeenum;@Min(0)ongenerationparam (invalid → 400).RelationshipService, notPersonRelationshipService).recordDTOs with@Schema(requiredMode = REQUIRED)on always-populated fields;senderName/receiverNameareString(never null, fallback"");eventIdnull for derived + letters; rawprecisionenum exposed; year bands ordered ascending.TimelineEntryDTO: documentderived == true || eventId == nullas the "no edit affordance" contract for issue #7.@RequirePermission(Permission.READ_ALL)on the controller method.npm run generate:apiand verify generated TS types are non-optional where@Schema(requiredMode = REQUIRED)is annotated.docs/architecture/c4/l3-backend-*.pumlfor the newtimeline/domain (newTimelineServiceread methods + DTOs); (2) addtimeline/entry to the package structure table inCLAUDE.md. No ADR triggered by this issue itself (the ADR for thetimeline/domain is allocated when the entity issue #2 lands).Acceptance criteria
documentDateis null OR precision isUNKNOWN.?personId=returns only that person's events + sent/received letters; a letter where the person is both sender and receiver appears once.type,fromYear/toYear,generation,personId) narrow results correctly and combine with logical AND.personId+generationcombined: AND logic applies strictly — if the target person's generation does not match thegenerationfilter, the result is empty.generation=Nwhen its sender OR receiver is in generation N; not duplicated if both match.generationdoes not appear in any?generation=Nfiltered response.?type=filters only the event layer; letters always pass.eventDate.getYear()(start year), consistent with its band placement. A RANGE event whose start year is outside[fromYear, toYear]is excluded even if its end year falls within range.yearsandundated.fromYear > toYearreturns 400;fromYear == toYearis an inclusive single-year window.generation < 0returns 400 (Bean Validation@Min(0)).personIdreturns 404 withPERSON_NOT_FOUND.precisionenum,eventDate, nullableeventDateEnd,derived,type, andsenderName/receiverNameas non-null strings with free-text fallback;eventIdis null for derived entries and letters;documentIdis null for events.Tests (pyramid: bulk of coverage as fast Mockito unit tests <10s; Testcontainers
postgres:16-alpine, never H2)TimelineServiceTest(Mockito, mock at the service boundary) — ~18-22 cases. TDD order:TimelineDTOYEARletter → one year band with one entryUNKNOWN-precision letter → undated (two separate code paths, both tested)senderNameis""(not NPE, not null)DAYvsYEAR→DAYsorts first (assert the full sequence, not "contains")DAYprecision, same date → sorted by title alphabetically (exercises title tiebreak inWITHIN_BAND_ORDER)RANGEevent → appears only in start-year band; open-endedRANGEwith nulleventDateEnd→ start-year band, no crashfromYear/toYear: event with start year outside range is excluded even if end year overlapstype(letters survive),fromYear/toYear(inclusive;fromYear == toYearsingle-year window — guard against off-by-one),generation(sender-or-receiver match, deduped),personIdtype=HISTORICAL+fromYear=1914+toYear=1918→ AND composition verifiedpersonIdscoping returns only sent+received letters + that person's derived events; same-person sender+receiver letter appears oncepersonId+generationcombined → empty result when person's generation doesn't match filtergeneration=N→ not returnedmakeLetter(year, precision)/makeEvent(...)factories so each test body is one line of setup + one assertion. ThemakeLetterfactory must support anullSenderAndNullSenderTextvariant.TimelineServiceIntegrationTest(@DataJpaTest+ Testcontainers postgres:16-alpine) — curated-event persistence + assembled read against real Postgres (LocalDate/null column behavior); a handful of cases.TimelineControllerTest(@WebMvcTest+SecurityConfig+PermissionAspect+AopAutoConfiguration) — following the pattern inUserSearchControllerTest:READ_ALLtypevalue (unknown enum string)fromYear > toYeargeneration=-1(Bean Validation@Min(0))PERSON_NOT_FOUNDbody when service throwsDomainException.notFound(mockTimelineService.assemble(...)to throw; assert status + error code)