From a1e57ff8cfc77d8562478ca6de73d52a4268eb41 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 14 Jun 2026 10:58:25 +0200 Subject: [PATCH] feat(timeline): derive header meta figures from the DTO (REQ-002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pure timelineMeta() returns the year range (first/last band, null when there are no bands) and the letter/event totals across all year bands plus the undated bucket — the single place these counts are computed. Refs #833 Co-Authored-By: Claude Opus 4.8 --- .../src/lib/timeline/timelineMeta.spec.ts | 56 +++++++++++++++++++ frontend/src/lib/timeline/timelineMeta.ts | 31 ++++++++++ 2 files changed, 87 insertions(+) create mode 100644 frontend/src/lib/timeline/timelineMeta.spec.ts create mode 100644 frontend/src/lib/timeline/timelineMeta.ts diff --git a/frontend/src/lib/timeline/timelineMeta.spec.ts b/frontend/src/lib/timeline/timelineMeta.spec.ts new file mode 100644 index 00000000..08a4f0b4 --- /dev/null +++ b/frontend/src/lib/timeline/timelineMeta.spec.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { timelineMeta } from './timelineMeta'; +import { makeEntry, makeYear, makeTimelineDTO } from './test-factories'; + +const letter = (id: string) => makeEntry({ kind: 'LETTER', documentId: id }); +const event = (title: string) => + makeEntry({ + kind: 'EVENT', + derived: true, + derivedType: 'BIRTH', + title, + senderName: '', + receiverName: '', + documentId: undefined + }); + +describe('timelineMeta', () => { + it('counts letters and events across year bands and the undated bucket (REQ-002)', () => { + const dto = makeTimelineDTO({ + years: [ + makeYear(1909, [letter('a'), event('Geburt'), letter('b')]), + makeYear(1924, [event('Tod')]) + ], + undated: [letter('c'), event('Heirat')] + }); + const meta = timelineMeta(dto); + expect(meta.letterCount).toBe(3); + expect(meta.eventCount).toBe(3); + }); + + it('reads the range from the first and last year band (REQ-002)', () => { + const dto = makeTimelineDTO({ + years: [makeYear(1909, [letter('a')]), makeYear(1924, [letter('b')])] + }); + const meta = timelineMeta(dto); + expect(meta.firstYear).toBe(1909); + expect(meta.lastYear).toBe(1924); + }); + + it('has a null range when there are no year bands, but still counts undated (REQ-002)', () => { + const dto = makeTimelineDTO({ undated: [letter('a')] }); + const meta = timelineMeta(dto); + expect(meta.firstYear).toBeNull(); + expect(meta.lastYear).toBeNull(); + expect(meta.letterCount).toBe(1); + }); + + it('reports zero counts and a null range for an empty timeline (REQ-002)', () => { + expect(timelineMeta(makeTimelineDTO())).toEqual({ + firstYear: null, + lastYear: null, + letterCount: 0, + eventCount: 0 + }); + }); +}); diff --git a/frontend/src/lib/timeline/timelineMeta.ts b/frontend/src/lib/timeline/timelineMeta.ts new file mode 100644 index 00000000..7052c405 --- /dev/null +++ b/frontend/src/lib/timeline/timelineMeta.ts @@ -0,0 +1,31 @@ +import type { components } from '$lib/generated/api'; + +type TimelineDTO = components['schemas']['TimelineDTO']; + +export interface TimelineMeta { + /** First year band's year, or `null` when there are no bands. */ + firstYear: number | null; + /** Last year band's year, or `null` when there are no bands. */ + lastYear: number | null; + /** Every `LETTER` entry across all year bands plus the undated bucket. */ + letterCount: number; + /** Every `EVENT` entry (derived, curated, and historical) across all bands plus undated. */ + eventCount: number; +} + +/** + * Derives the header meta-line figures from a loaded `TimelineDTO` (REQ-002): + * the year range (first/last band) and the letter/event totals across every + * year band plus the undated bucket. Pure and the single place these counts + * live — the route renders them; `TimelineView` never recomputes them. + */ +export function timelineMeta(timeline: TimelineDTO): TimelineMeta { + const years = timeline.years; + const allEntries = [...years.flatMap((y) => y.entries), ...timeline.undated]; + return { + firstYear: years.length ? years[0].year : null, + lastYear: years.length ? years[years.length - 1].year : null, + letterCount: allEntries.filter((e) => e.kind === 'LETTER').length, + eventCount: allEntries.filter((e) => e.kind === 'EVENT').length + }; +}