From 1dc3b914580c5812f228055d2b151ea051ff6b5a Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 19:18:50 +0200 Subject: [PATCH 01/22] refactor(timeline): move pure month-bucket math to $lib/shared/utils/monthBuckets Relocate the 10 pure helpers (monthBoundaryFrom/To, buildMonthSequence, fillDensityGaps, clipBucketsToRange, aggregateToYears, selectionBoundaryFrom/To, tickIndicesFor, formatTickLabel) and their unit tests out of document/timeline.ts into a shared module so lib/timeline/ can consume them without importing lib/document/. The /api/documents/density glue (buildDensityUrl, fetchDensity, DensityState, DensityFilters) stays in document/timeline.ts. Re-point the three density components and the density-filter spec at the shared module. Refs #779 Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/document/TimelineBars.svelte | 2 +- .../lib/document/TimelineDensityFilter.svelte | 2 +- .../TimelineDensityFilter.svelte.spec.ts | 2 +- .../src/lib/document/TimelineXAxis.svelte | 2 +- frontend/src/lib/document/timeline.spec.ts | 269 +----------------- frontend/src/lib/document/timeline.ts | 154 ---------- .../src/lib/shared/utils/monthBuckets.spec.ts | 267 +++++++++++++++++ frontend/src/lib/shared/utils/monthBuckets.ts | 163 +++++++++++ 8 files changed, 435 insertions(+), 426 deletions(-) create mode 100644 frontend/src/lib/shared/utils/monthBuckets.spec.ts create mode 100644 frontend/src/lib/shared/utils/monthBuckets.ts diff --git a/frontend/src/lib/document/TimelineBars.svelte b/frontend/src/lib/document/TimelineBars.svelte index fb0db1ca..c5784028 100644 --- a/frontend/src/lib/document/TimelineBars.svelte +++ b/frontend/src/lib/document/TimelineBars.svelte @@ -1,6 +1,6 @@ + + diff --git a/frontend/src/lib/shared/primitives/Sparkline.svelte.spec.ts b/frontend/src/lib/shared/primitives/Sparkline.svelte.spec.ts new file mode 100644 index 00000000..228302b0 --- /dev/null +++ b/frontend/src/lib/shared/primitives/Sparkline.svelte.spec.ts @@ -0,0 +1,28 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import Sparkline from './Sparkline.svelte'; + +afterEach(() => cleanup()); + +describe('Sparkline', () => { + it('renders one bar per value', () => { + render(Sparkline, { values: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] }); + const bars = document.querySelectorAll('[data-testid="sparkline-bar"]'); + expect(bars).toHaveLength(12); + }); + + it('scales bar heights relative to the largest value', () => { + render(Sparkline, { values: [5, 10, 0] }); + const bars = document.querySelectorAll('[data-testid="sparkline-bar"]'); + const h = (i: number) => parseFloat(bars[i].style.height); + // 10 is the max → tallest; 5 is half of the max's height; 0 is the shortest. + expect(h(1)).toBeGreaterThan(h(0)); + expect(h(0)).toBeGreaterThan(h(2)); + }); + + it('exposes an accessible label when provided', () => { + render(Sparkline, { values: [1, 2, 3], label: 'Monatsdichte' }); + const img = document.querySelector('[role="img"]'); + expect(img?.getAttribute('aria-label')).toBe('Monatsdichte'); + }); +}); -- 2.49.1 From 6a35e8510b9939c299d7ce0498f7c314b383a955 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 19:26:29 +0200 Subject: [PATCH 05/22] feat(timeline): add eventCardConfig accent matrix + DTO test factories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getAccentConfig(entry) maps each EVENT to its glyph (* / † / ⚭ / ★ / ◍), German redundant-cue label, and accent kind (REQ-007/008/018). test-factories build TimelineEntryDTO/TimelineDTO mirroring the real wire shape for component specs. Refs #779 Co-Authored-By: Claude Opus 4.8 --- .../src/lib/timeline/eventCardConfig.spec.ts | 53 +++++++++++++++++++ frontend/src/lib/timeline/eventCardConfig.ts | 38 +++++++++++++ frontend/src/lib/timeline/test-factories.ts | 34 ++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 frontend/src/lib/timeline/eventCardConfig.spec.ts create mode 100644 frontend/src/lib/timeline/eventCardConfig.ts create mode 100644 frontend/src/lib/timeline/test-factories.ts diff --git a/frontend/src/lib/timeline/eventCardConfig.spec.ts b/frontend/src/lib/timeline/eventCardConfig.spec.ts new file mode 100644 index 00000000..8fd33355 --- /dev/null +++ b/frontend/src/lib/timeline/eventCardConfig.spec.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { getAccentConfig } from './eventCardConfig'; +import type { components } from '$lib/generated/api'; + +type TimelineEntryDTO = components['schemas']['TimelineEntryDTO']; + +function event(overrides: Partial): TimelineEntryDTO { + return { + kind: 'EVENT', + precision: 'YEAR', + derived: false, + senderName: '', + receiverName: '', + ...overrides + }; +} + +describe('getAccentConfig', () => { + it('maps a derived birth to the * glyph and "Geburt"', () => { + const cfg = getAccentConfig(event({ derived: true, derivedType: 'BIRTH' })); + expect(cfg.glyph).toBe('*'); + expect(cfg.label).toBe('Geburt'); + expect(cfg.accent).toBe('derived'); + }); + + it('maps a derived death to the † glyph and "Tod"', () => { + const cfg = getAccentConfig(event({ derived: true, derivedType: 'DEATH' })); + expect(cfg.glyph).toBe('†'); + expect(cfg.label).toBe('Tod'); + expect(cfg.accent).toBe('derived'); + }); + + it('maps a derived marriage to the ⚭ glyph and "Heirat"', () => { + const cfg = getAccentConfig(event({ derived: true, derivedType: 'MARRIAGE' })); + expect(cfg.glyph).toBe('⚭'); + expect(cfg.label).toBe('Heirat'); + expect(cfg.accent).toBe('derived'); + }); + + it('maps a HISTORICAL event to the world glyph and "Weltgeschehen"', () => { + const cfg = getAccentConfig(event({ type: 'HISTORICAL' })); + expect(cfg.glyph).toBe('◍'); + expect(cfg.label).toBe('Weltgeschehen'); + expect(cfg.accent).toBe('historical'); + }); + + it('maps a curated PERSONAL event to the ★ glyph and "Familie"', () => { + const cfg = getAccentConfig(event({ type: 'PERSONAL', eventId: 'e-1' })); + expect(cfg.glyph).toBe('★'); + expect(cfg.label).toBe('Familie'); + expect(cfg.accent).toBe('curated'); + }); +}); diff --git a/frontend/src/lib/timeline/eventCardConfig.ts b/frontend/src/lib/timeline/eventCardConfig.ts new file mode 100644 index 00000000..715249b3 --- /dev/null +++ b/frontend/src/lib/timeline/eventCardConfig.ts @@ -0,0 +1,38 @@ +import * as m from '$lib/paraglide/messages.js'; +import type { components } from '$lib/generated/api'; + +type TimelineEntryDTO = components['schemas']['TimelineEntryDTO']; + +/** Styling discriminant for an axis pill/band. */ +export type TimelineAccent = 'derived' | 'curated' | 'historical'; + +export interface AccentConfig { + /** Visible Unicode glyph — render `aria-hidden`, paired with an sr-only label. */ + glyph: string; + /** German layer/life-event label — used as the sr-only text and as visible chrome. */ + label: string; + accent: TimelineAccent; +} + +/** + * Maps a timeline EVENT entry to its glyph, redundant non-color label, and accent + * (REQ-007/008/018). Derived life-events use the * / † / ⚭ glyphs that match + * `personLifeDates.ts`; HISTORICAL events get the muted world band; everything + * else (curated PERSONAL) gets the mint family pill. + */ +export function getAccentConfig(entry: TimelineEntryDTO): AccentConfig { + if (entry.derived) { + switch (entry.derivedType) { + case 'BIRTH': + return { glyph: '*', label: m.timeline_derived_birth(), accent: 'derived' }; + case 'DEATH': + return { glyph: '†', label: m.timeline_derived_death(), accent: 'derived' }; + case 'MARRIAGE': + return { glyph: '⚭', label: m.timeline_derived_marriage(), accent: 'derived' }; + } + } + if (entry.type === 'HISTORICAL') { + return { glyph: '◍', label: m.timeline_layer_world(), accent: 'historical' }; + } + return { glyph: '★', label: m.timeline_layer_family(), accent: 'curated' }; +} diff --git a/frontend/src/lib/timeline/test-factories.ts b/frontend/src/lib/timeline/test-factories.ts new file mode 100644 index 00000000..25bc3026 --- /dev/null +++ b/frontend/src/lib/timeline/test-factories.ts @@ -0,0 +1,34 @@ +import type { components } from '$lib/generated/api'; + +type TimelineEntryDTO = components['schemas']['TimelineEntryDTO']; +type TimelineYearDTO = components['schemas']['TimelineYearDTO']; +type TimelineDTO = components['schemas']['TimelineDTO']; + +/** + * Builds a `TimelineEntryDTO` mirroring the real wire shape (no `year`, + * `description`, or `snippet` fields). Defaults to a dated DAY-precision letter; + * override `kind`/`derived`/`type`/`derivedType` etc. for events. + */ +export function makeEntry(overrides: Partial = {}): TimelineEntryDTO { + return { + kind: 'LETTER', + precision: 'DAY', + derived: false, + senderName: 'Karl Raddatz', + receiverName: 'Elfriede Raddatz', + eventDate: '1915-06-15', + title: 'Brief aus dem Feld', + documentId: '11111111-1111-1111-1111-111111111111', + ...overrides + }; +} + +export function makeYear(year: number, entries: TimelineEntryDTO[]): TimelineYearDTO { + return { year, entries }; +} + +export function makeTimelineDTO( + opts: { years?: TimelineYearDTO[]; undated?: TimelineEntryDTO[] } = {} +): TimelineDTO { + return { years: opts.years ?? [], undated: opts.undated ?? [] }; +} -- 2.49.1 From e25001f7c9b9c59fb795c00ccddca15dd4095bd1 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 19:30:55 +0200 Subject: [PATCH 06/22] feat(timeline): add LetterCard component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single archive letter: sender → receiver (Unbekannt fallback for empty names, REQ-014), title, precision date chip via timelineDateLabel (omitted when null, REQ-013), linking to exactly /documents/{documentId} with no target (REQ-023). 44px touch target enforced inline + focus-visible ring (REQ-020). OCR/import text via {...} escaping + whitespace-pre-line, no {@html} (REQ-021). Refs #779 Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/timeline/LetterCard.svelte | 42 ++++++++++++++ .../lib/timeline/LetterCard.svelte.spec.ts | 58 +++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 frontend/src/lib/timeline/LetterCard.svelte create mode 100644 frontend/src/lib/timeline/LetterCard.svelte.spec.ts diff --git a/frontend/src/lib/timeline/LetterCard.svelte b/frontend/src/lib/timeline/LetterCard.svelte new file mode 100644 index 00000000..f5f90302 --- /dev/null +++ b/frontend/src/lib/timeline/LetterCard.svelte @@ -0,0 +1,42 @@ + + + + + {#if entry.title} + {entry.title} + {/if} + + {sender} + + {receiver} + {#if dateLabel} + · {dateLabel} + {/if} + + diff --git a/frontend/src/lib/timeline/LetterCard.svelte.spec.ts b/frontend/src/lib/timeline/LetterCard.svelte.spec.ts new file mode 100644 index 00000000..28df3886 --- /dev/null +++ b/frontend/src/lib/timeline/LetterCard.svelte.spec.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import LetterCard from './LetterCard.svelte'; +import { timelineDateLabel } from './dateLabel'; +import { makeEntry } from './test-factories'; + +afterEach(() => cleanup()); + +const DOC_ID = '22222222-2222-2222-2222-222222222222'; + +describe('LetterCard', () => { + it('renders sender, receiver, and title', () => { + render(LetterCard, { + entry: makeEntry({ senderName: 'Karl', receiverName: 'Elfriede', title: 'Feldpost' }) + }); + expect(document.body.textContent).toContain('Karl'); + expect(document.body.textContent).toContain('Elfriede'); + expect(document.body.textContent).toContain('Feldpost'); + }); + + it('renders the precision date exactly as timelineDateLabel returns (REQ-013)', () => { + const entry = makeEntry({ eventDate: '1915-06-15', precision: 'MONTH' }); + const expected = timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd); + expect(expected).toBeTruthy(); + render(LetterCard, { entry }); + expect(document.body.textContent).toContain(expected as string); + }); + + it('renders no date chip when timelineDateLabel returns null (REQ-013)', () => { + const entry = makeEntry({ precision: 'UNKNOWN', eventDate: undefined }); + render(LetterCard, { entry }); + const chip = document.querySelector('[data-testid="letter-date"]'); + expect(chip).toBeNull(); + }); + + it('shows "Unbekannt" for an empty sender, never a bare arrow (REQ-014)', () => { + render(LetterCard, { entry: makeEntry({ senderName: '', receiverName: 'Elfriede' }) }); + expect(document.body.textContent).toContain('Unbekannt'); + }); + + it('shows "Unbekannt" for an empty receiver (REQ-014)', () => { + render(LetterCard, { entry: makeEntry({ senderName: 'Karl', receiverName: '' }) }); + expect(document.body.textContent).toContain('Unbekannt'); + }); + + it('links to exactly /documents/{documentId} with no target (REQ-023)', () => { + render(LetterCard, { entry: makeEntry({ documentId: DOC_ID }) }); + const link = document.querySelector('a') as HTMLAnchorElement; + expect(link.getAttribute('href')).toBe(`/documents/${DOC_ID}`); + expect(link.hasAttribute('target')).toBe(false); + }); + + it('has a touch target of at least 44px (REQ-020)', () => { + render(LetterCard, { entry: makeEntry() }); + const link = document.querySelector('a') as HTMLAnchorElement; + expect(link.getBoundingClientRect().height).toBeGreaterThanOrEqual(44); + }); +}); -- 2.49.1 From b031f2736bda5839023600c92081fff0d509f2cd Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 19:36:03 +0200 Subject: [PATCH 07/22] feat(timeline): add EventPill for derived + curated event pills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Centered axis pill: derived life-events (* Geburt / † Tod / ⚭ Heirat) and curated PERSONAL events (★, mint border) via getAccentConfig. Glyph wrapped aria-hidden + sr-only label (REQ-018). Edit affordance only for a curated event with eventId, never derived/null (REQ-008). REQ-007. Refs #779 Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/timeline/EventPill.svelte | 59 ++++++++++++ .../src/lib/timeline/EventPill.svelte.spec.ts | 90 +++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 frontend/src/lib/timeline/EventPill.svelte create mode 100644 frontend/src/lib/timeline/EventPill.svelte.spec.ts diff --git a/frontend/src/lib/timeline/EventPill.svelte b/frontend/src/lib/timeline/EventPill.svelte new file mode 100644 index 00000000..9b125cb7 --- /dev/null +++ b/frontend/src/lib/timeline/EventPill.svelte @@ -0,0 +1,59 @@ + + +
+
+ + + {config.label} + + + {#if entry.title} + {entry.title} + {/if} + {#if dateLabel} + {dateLabel} + {/if} + + {#if canEdit} + + + {m.btn_edit()} + + {/if} +
+
diff --git a/frontend/src/lib/timeline/EventPill.svelte.spec.ts b/frontend/src/lib/timeline/EventPill.svelte.spec.ts new file mode 100644 index 00000000..945ea65f --- /dev/null +++ b/frontend/src/lib/timeline/EventPill.svelte.spec.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import EventPill from './EventPill.svelte'; +import { makeEntry } from './test-factories'; + +afterEach(() => cleanup()); + +const EVENT_ID = '33333333-3333-3333-3333-333333333333'; + +function derived(derivedType: 'BIRTH' | 'DEATH' | 'MARRIAGE', title: string) { + return makeEntry({ + kind: 'EVENT', + derived: true, + derivedType, + title, + senderName: '', + receiverName: '', + precision: 'YEAR', + eventDate: '1914-01-01', + documentId: undefined + }); +} + +describe('EventPill', () => { + it('renders a derived marriage as ⚭ + "Heirat" + title (REQ-007)', () => { + render(EventPill, { entry: derived('MARRIAGE', 'Heirat: Karl & Elfriede') }); + expect(document.body.textContent).toContain('⚭'); + expect(document.body.textContent).toContain('Heirat'); + expect(document.body.textContent).toContain('Heirat: Karl & Elfriede'); + }); + + it('renders a derived birth as * + "Geburt" (REQ-007)', () => { + render(EventPill, { entry: derived('BIRTH', 'Geburt: Hans') }); + expect(document.body.textContent).toContain('*'); + expect(document.body.textContent).toContain('Geburt'); + }); + + it('renders a derived death as † + "Tod" (REQ-007)', () => { + render(EventPill, { entry: derived('DEATH', 'Tod: Karl') }); + expect(document.body.textContent).toContain('†'); + expect(document.body.textContent).toContain('Tod'); + }); + + it('wraps the glyph aria-hidden with an sr-only label sibling (REQ-018)', () => { + render(EventPill, { entry: derived('BIRTH', 'Geburt: Hans') }); + const hidden = document.querySelector('[aria-hidden="true"]'); + expect(hidden?.textContent).toBe('*'); + const srOnly = document.querySelector('.sr-only'); + expect(srOnly?.textContent).toBe('Geburt'); + }); + + it('shows an edit affordance for a curated PERSONAL event with an eventId (REQ-008)', () => { + render(EventPill, { + entry: makeEntry({ + kind: 'EVENT', + derived: false, + type: 'PERSONAL', + eventId: EVENT_ID, + title: 'Auswanderung', + senderName: '', + receiverName: '', + documentId: undefined + }) + }); + const edit = document.querySelector('[data-testid="event-edit"]') as HTMLAnchorElement | null; + expect(edit).not.toBeNull(); + expect(edit?.getAttribute('href')).toContain(EVENT_ID); + }); + + it('shows no edit affordance when eventId is null (REQ-008)', () => { + render(EventPill, { + entry: makeEntry({ + kind: 'EVENT', + derived: false, + type: 'PERSONAL', + eventId: undefined, + title: 'Auswanderung', + senderName: '', + receiverName: '', + documentId: undefined + }) + }); + expect(document.querySelector('[data-testid="event-edit"]')).toBeNull(); + }); + + it('shows no edit affordance for a derived event (REQ-008)', () => { + render(EventPill, { entry: derived('MARRIAGE', 'Heirat') }); + expect(document.querySelector('[data-testid="event-edit"]')).toBeNull(); + }); +}); -- 2.49.1 From e75448ba1483a3c7a4d7b5a22dd4bb6f1c3183ce Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 19:36:21 +0200 Subject: [PATCH 08/22] feat(timeline): add WorldBand for HISTORICAL context bands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-width muted band; RANGE renders a span pill (1914–1918) with a Zeitraum aria-label (REQ-009); a RANGE with no end degrades to the start year, no pill, no crash (REQ-010). World glyph is a redundant non-color cue with sr-only label (REQ-018); text uses text-ink-2 to hold AA in both themes (REQ-019). Refs #779 Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/timeline/WorldBand.svelte | 43 +++++++++++++++++ .../src/lib/timeline/WorldBand.svelte.spec.ts | 48 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 frontend/src/lib/timeline/WorldBand.svelte create mode 100644 frontend/src/lib/timeline/WorldBand.svelte.spec.ts diff --git a/frontend/src/lib/timeline/WorldBand.svelte b/frontend/src/lib/timeline/WorldBand.svelte new file mode 100644 index 00000000..92519535 --- /dev/null +++ b/frontend/src/lib/timeline/WorldBand.svelte @@ -0,0 +1,43 @@ + + +
+ + + {config.label} + {entry.title} + + {#if showSpan && fromYear && toYear} + + {fromYear}–{toYear} + + {:else if dateText} + {dateText} + {/if} +
diff --git a/frontend/src/lib/timeline/WorldBand.svelte.spec.ts b/frontend/src/lib/timeline/WorldBand.svelte.spec.ts new file mode 100644 index 00000000..497f51b3 --- /dev/null +++ b/frontend/src/lib/timeline/WorldBand.svelte.spec.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import WorldBand from './WorldBand.svelte'; +import { makeEntry } from './test-factories'; + +afterEach(() => cleanup()); + +function historical(overrides = {}) { + return makeEntry({ + kind: 'EVENT', + derived: false, + type: 'HISTORICAL', + title: 'Erster Weltkrieg', + senderName: '', + receiverName: '', + precision: 'RANGE', + eventDate: '1914-01-01', + eventDateEnd: '1918-12-31', + documentId: undefined, + ...overrides + }); +} + +describe('WorldBand', () => { + it('renders the historical title with the world glyph + "Weltgeschehen" cue (REQ-018)', () => { + render(WorldBand, { entry: historical() }); + expect(document.body.textContent).toContain('Erster Weltkrieg'); + const hidden = document.querySelector('[aria-hidden="true"]'); + expect(hidden?.textContent).toBe('◍'); + const srOnly = document.querySelector('.sr-only'); + expect(srOnly?.textContent).toBe('Weltgeschehen'); + }); + + it('renders a RANGE span pill 1914–1918 with a Zeitraum aria-label (REQ-009)', () => { + render(WorldBand, { entry: historical() }); + const pill = document.querySelector('[data-testid="world-range"]'); + expect(pill).not.toBeNull(); + expect(pill?.textContent).toContain('1914–1918'); + expect(pill?.getAttribute('aria-label')).toBe('Zeitraum: 1914 bis 1918'); + }); + + it('degrades a RANGE with no end to the start year, no span pill, no crash (REQ-010)', () => { + render(WorldBand, { entry: historical({ eventDateEnd: undefined }) }); + expect(document.querySelector('[data-testid="world-range"]')).toBeNull(); + expect(document.body.textContent).toContain('Erster Weltkrieg'); + expect(document.body.textContent).toContain('1914'); + }); +}); -- 2.49.1 From bea0e0d0565349f8bf638f8ebcf35edbac5f3b6b Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 19:36:39 +0200 Subject: [PATCH 09/22] feat(timeline): add GapSpan for folded empty-year runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A thin dashed span rendering '{from}–{to} · keine Einträge', collapsing to a single year when the run has length 1 (REQ-015). Refs #779 Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/timeline/GapSpan.svelte | 20 +++++++++++++++++++ .../src/lib/timeline/GapSpan.svelte.spec.ts | 20 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 frontend/src/lib/timeline/GapSpan.svelte create mode 100644 frontend/src/lib/timeline/GapSpan.svelte.spec.ts diff --git a/frontend/src/lib/timeline/GapSpan.svelte b/frontend/src/lib/timeline/GapSpan.svelte new file mode 100644 index 00000000..ba8aa18a --- /dev/null +++ b/frontend/src/lib/timeline/GapSpan.svelte @@ -0,0 +1,20 @@ + + +
+ + {yearLabel} · {m.timeline_gap_empty()} + +
diff --git a/frontend/src/lib/timeline/GapSpan.svelte.spec.ts b/frontend/src/lib/timeline/GapSpan.svelte.spec.ts new file mode 100644 index 00000000..57df8a33 --- /dev/null +++ b/frontend/src/lib/timeline/GapSpan.svelte.spec.ts @@ -0,0 +1,20 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import GapSpan from './GapSpan.svelte'; + +afterEach(() => cleanup()); + +describe('GapSpan', () => { + it('renders a multi-year empty run as "{from}–{to} · keine Einträge" (REQ-015)', () => { + render(GapSpan, { from: 1910, to: 1914 }); + expect(document.body.textContent).toContain('1910–1914'); + expect(document.body.textContent).toContain('keine Einträge'); + }); + + it('renders a single empty year as "{year} · keine Einträge" (REQ-015)', () => { + render(GapSpan, { from: 1912, to: 1912 }); + expect(document.body.textContent).toContain('1912'); + expect(document.body.textContent).not.toContain('1912–1912'); + expect(document.body.textContent).toContain('keine Einträge'); + }); +}); -- 2.49.1 From 5bff428954f5c617e6ecea877be32c99a8c1dad3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 19:36:57 +0200 Subject: [PATCH 10/22] feat(timeline): add YearLetterStrip for dense years Letter count + 12-month density sparkline + a >=44px keyboard-focusable expand toggle that reveals that year's LetterCards (REQ-012). Sparkline values from the shared monthHistogram. Refs #779 Co-Authored-By: Claude Opus 4.8 --- .../src/lib/timeline/YearLetterStrip.svelte | 52 +++++++++++++++++++ .../timeline/YearLetterStrip.svelte.spec.ts | 42 +++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 frontend/src/lib/timeline/YearLetterStrip.svelte create mode 100644 frontend/src/lib/timeline/YearLetterStrip.svelte.spec.ts diff --git a/frontend/src/lib/timeline/YearLetterStrip.svelte b/frontend/src/lib/timeline/YearLetterStrip.svelte new file mode 100644 index 00000000..8409734c --- /dev/null +++ b/frontend/src/lib/timeline/YearLetterStrip.svelte @@ -0,0 +1,52 @@ + + +
+
+ {m.timeline_letters_count({ count: letters.length })} + +
+ + + + {#if expanded} +
    + {#each letters as letter (letter.documentId)} +
  • + {/each} +
+ {/if} +
diff --git a/frontend/src/lib/timeline/YearLetterStrip.svelte.spec.ts b/frontend/src/lib/timeline/YearLetterStrip.svelte.spec.ts new file mode 100644 index 00000000..4c78ea0e --- /dev/null +++ b/frontend/src/lib/timeline/YearLetterStrip.svelte.spec.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { tick } from 'svelte'; +import YearLetterStrip from './YearLetterStrip.svelte'; +import { makeEntry } from './test-factories'; + +afterEach(() => cleanup()); + +function denseLetters(year: number, count: number) { + return Array.from({ length: count }, (_, i) => + makeEntry({ + eventDate: `${year}-${String((i % 12) + 1).padStart(2, '0')}-10`, + documentId: `doc-${i}` + }) + ); +} + +describe('YearLetterStrip', () => { + it('shows the letter count and a 12-bar sparkline (REQ-012)', () => { + render(YearLetterStrip, { letters: denseLetters(1915, 30), year: 1915 }); + expect(document.body.textContent).toContain('30'); + const bars = document.querySelectorAll('[data-testid="sparkline-bar"]'); + expect(bars).toHaveLength(12); + }); + + it('has a keyboard-focusable expand toggle of at least 44px (REQ-012)', () => { + render(YearLetterStrip, { letters: denseLetters(1915, 30), year: 1915 }); + const toggle = document.querySelector('[data-testid="strip-expand"]') as HTMLButtonElement; + expect(toggle).not.toBeNull(); + expect(toggle.tagName).toBe('BUTTON'); + expect(toggle.getBoundingClientRect().height).toBeGreaterThanOrEqual(44); + }); + + it('reveals all letter cards when expanded (REQ-012)', async () => { + render(YearLetterStrip, { letters: denseLetters(1915, 30), year: 1915 }); + expect(document.querySelectorAll('a').length).toBe(0); + const toggle = document.querySelector('[data-testid="strip-expand"]') as HTMLButtonElement; + toggle.click(); + await tick(); + expect(document.querySelectorAll('a').length).toBe(30); + }); +}); -- 2.49.1 From f9ddcf0374b20a6a067133788a6f960e17be7ecc Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 19:43:47 +0200 Subject: [PATCH 11/22] feat(timeline): add YearBand (section + sticky h2, cards vs strip) One
per year with a sticky

at top:4rem (REQ-006). Events render in DTO order as pills/bands; letters render as individual cards while <= 12 (REQ-011) or collapse to one density strip above that (REQ-012); DTO order is never re-sorted (REQ-003). Letters carry an alternating data-side for the centered desktop axis (REQ-004); single left column on phone (REQ-005). Derived-safe {#each} key. Refs #779 Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/timeline/YearBand.svelte | 108 ++++++++++++++++++ .../src/lib/timeline/YearBand.svelte.spec.ts | 84 ++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 frontend/src/lib/timeline/YearBand.svelte create mode 100644 frontend/src/lib/timeline/YearBand.svelte.spec.ts diff --git a/frontend/src/lib/timeline/YearBand.svelte b/frontend/src/lib/timeline/YearBand.svelte new file mode 100644 index 00000000..005e6e7e --- /dev/null +++ b/frontend/src/lib/timeline/YearBand.svelte @@ -0,0 +1,108 @@ + + +
+

+ {year.year} +

+ +
+ {#each rows as row (row.t === 'strip' ? `strip-${year.year}` : entryKey(row.entry))} + {#if row.t === 'event'} + {#if row.entry.type === 'HISTORICAL'} + + {:else} + + {/if} + {:else if row.t === 'letter'} +
+ +
+ {:else} + + {/if} + {/each} +
+
+ + diff --git a/frontend/src/lib/timeline/YearBand.svelte.spec.ts b/frontend/src/lib/timeline/YearBand.svelte.spec.ts new file mode 100644 index 00000000..5d43a6e5 --- /dev/null +++ b/frontend/src/lib/timeline/YearBand.svelte.spec.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import YearBand from './YearBand.svelte'; +import { makeEntry, makeYear } from './test-factories'; + +afterEach(() => cleanup()); + +function manyLetters(year: number, count: number) { + return Array.from({ length: count }, (_, i) => + makeEntry({ eventDate: `${year}-01-10`, documentId: `doc-${i}` }) + ); +} + +describe('YearBand', () => { + it('renders a section with a sticky h2 at top:4rem showing the year (REQ-006)', () => { + render(YearBand, { year: makeYear(1914, [makeEntry()]) }); + const section = document.querySelector('section'); + expect(section).not.toBeNull(); + const h2 = section?.querySelector('h2'); + expect(h2?.textContent).toContain('1914'); + const cs = getComputedStyle(h2 as HTMLElement); + expect(cs.position).toBe('sticky'); + expect(cs.top).toBe('64px'); + }); + + it('renders each letter as a card when the band holds <= 12 letters (REQ-011)', () => { + render(YearBand, { year: makeYear(1909, manyLetters(1909, 3)) }); + expect(document.querySelectorAll('a')).toHaveLength(3); + expect(document.querySelector('[data-testid="strip-expand"]')).toBeNull(); + }); + + it('renders a single strip when the band holds > 12 letters (REQ-012)', () => { + render(YearBand, { year: makeYear(1915, manyLetters(1915, 30)) }); + expect(document.querySelector('[data-testid="strip-expand"]')).not.toBeNull(); + // collapsed: no individual letter links yet + expect(document.querySelectorAll('a')).toHaveLength(0); + }); + + it('renders entries in DTO order — DAY-precision letter above a YEAR-precision letter (REQ-003)', () => { + const dayLetter = makeEntry({ + precision: 'DAY', + eventDate: '1923-04-12', + title: 'Tagesgenau', + documentId: 'day' + }); + const yearLetter = makeEntry({ + precision: 'YEAR', + eventDate: '1923-01-01', + title: 'Nur Jahr', + documentId: 'year' + }); + render(YearBand, { year: makeYear(1923, [dayLetter, yearLetter]) }); + const links = Array.from(document.querySelectorAll('a')); + expect(links[0].getAttribute('href')).toBe('/documents/day'); + expect(links[1].getAttribute('href')).toBe('/documents/year'); + }); + + it('renders an EVENT as a pill and a HISTORICAL event as a band', () => { + const pill = makeEntry({ + kind: 'EVENT', + derived: true, + derivedType: 'MARRIAGE', + title: 'Heirat', + senderName: '', + receiverName: '', + documentId: undefined + }); + const band = makeEntry({ + kind: 'EVENT', + derived: false, + type: 'HISTORICAL', + precision: 'RANGE', + eventDate: '1914-01-01', + eventDateEnd: '1918-12-31', + title: 'Erster Weltkrieg', + senderName: '', + receiverName: '', + documentId: undefined + }); + render(YearBand, { year: makeYear(1914, [pill, band]) }); + expect(document.body.textContent).toContain('Heirat'); + expect(document.querySelector('[data-testid="world-range"]')).not.toBeNull(); + }); +}); -- 2.49.1 From 588314f862e9d0e02aad0431d9e5abfc44b476ed Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 19:44:05 +0200 Subject: [PATCH 12/22] feat(timeline): add TimelineView orchestrator Renders year bands in DTO order with interior empty-year runs folded into one GapSpan (REQ-015), a single
    in chronological DOM order (REQ-006), the undated bucket via {#if} (REQ-016), and a calm empty state (REQ-017). personId is a declared seam (issue #10), undefined here, never passed to leaf cards (REQ-025). Centered desktop spine / left phone spine via scoped CSS. Owns no
    . Refs #779 Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/timeline/TimelineView.svelte | 90 +++++++++ .../lib/timeline/TimelineView.svelte.spec.ts | 173 ++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 frontend/src/lib/timeline/TimelineView.svelte create mode 100644 frontend/src/lib/timeline/TimelineView.svelte.spec.ts diff --git a/frontend/src/lib/timeline/TimelineView.svelte b/frontend/src/lib/timeline/TimelineView.svelte new file mode 100644 index 00000000..e1732816 --- /dev/null +++ b/frontend/src/lib/timeline/TimelineView.svelte @@ -0,0 +1,90 @@ + + +{#if isEmpty} +

    {m.timeline_empty_state()}

    +{:else} + +
      + {#each rows as row (row.t === 'band' ? `band-${row.year.year}` : `gap-${row.from}`)} +
    1. + {#if row.t === 'band'} + + {:else} + + {/if} +
    2. + {/each} +
    + + {#if timeline.undated.length > 0} +
    +

    {m.timeline_undated_section()}

    +
      + {#each timeline.undated as entry (entry.documentId ?? entry.title)} +
    • + {/each} +
    +
    + {/if} +{/if} + + diff --git a/frontend/src/lib/timeline/TimelineView.svelte.spec.ts b/frontend/src/lib/timeline/TimelineView.svelte.spec.ts new file mode 100644 index 00000000..6d6422b3 --- /dev/null +++ b/frontend/src/lib/timeline/TimelineView.svelte.spec.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import TimelineView from './TimelineView.svelte'; +import { makeEntry, makeYear, makeTimelineDTO } from './test-factories'; + +afterEach(() => cleanup()); + +describe('TimelineView', () => { + it('shows the empty state and no ol when there are no years and no undated (REQ-017)', () => { + render(TimelineView, { timeline: makeTimelineDTO() }); + expect(document.body.textContent).toContain('Noch keine Ereignisse.'); + expect(document.querySelector('ol')).toBeNull(); + expect(document.querySelector('section')).toBeNull(); + }); + + it('renders the timeline as a single
      with each band a
      , ascending (REQ-006)', () => { + render(TimelineView, { + timeline: makeTimelineDTO({ + years: [ + makeYear(1914, [makeEntry({ documentId: 'a' })]), + makeYear(1916, [makeEntry({ documentId: 'b' })]) + ] + }) + }); + expect(document.querySelectorAll('ol')).toHaveLength(1); + const headings = Array.from(document.querySelectorAll('section h2')).map((h) => h.textContent); + expect(headings.some((t) => t?.includes('1914'))).toBe(true); + const order = headings.map((t) => t?.trim()); + expect(order.indexOf('1914')).toBeLessThan(order.indexOf('1916')); + }); + + it('folds an interior run of empty years into one GapSpan (REQ-015)', () => { + render(TimelineView, { + timeline: makeTimelineDTO({ + years: [ + makeYear(1909, [makeEntry({ documentId: 'a' })]), + makeYear(1915, [makeEntry({ documentId: 'b' })]) + ] + }) + }); + expect(document.body.textContent).toContain('1910–1914'); + expect(document.body.textContent).toContain('keine Einträge'); + }); + + it('folds a single empty interior year as a single year (REQ-015)', () => { + render(TimelineView, { + timeline: makeTimelineDTO({ + years: [ + makeYear(1911, [makeEntry({ documentId: 'a' })]), + makeYear(1913, [makeEntry({ documentId: 'b' })]) + ] + }) + }); + expect(document.body.textContent).toContain('1912'); + expect(document.body.textContent).not.toContain('1912–1912'); + }); + + it('renders an "Ohne Datum" section when undated is non-empty (REQ-016)', () => { + render(TimelineView, { + timeline: makeTimelineDTO({ + years: [makeYear(1914, [makeEntry({ documentId: 'a' })])], + undated: [makeEntry({ precision: 'UNKNOWN', eventDate: undefined, documentId: 'u1' })] + }) + }); + expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull(); + expect(document.body.textContent).toContain('Ohne Datum'); + }); + + it('omits the "Ohne Datum" section from the DOM when undated is empty (REQ-016)', () => { + render(TimelineView, { + timeline: makeTimelineDTO({ years: [makeYear(1914, [makeEntry({ documentId: 'a' })])] }) + }); + expect(document.querySelector('[data-testid="undated-section"]')).toBeNull(); + }); + + it('renders all years and undated entries with personId undefined, no filtering (REQ-025)', () => { + render(TimelineView, { + timeline: makeTimelineDTO({ + years: [ + makeYear(1914, [makeEntry({ documentId: 'a' })]), + makeYear(1915, [makeEntry({ documentId: 'b' })]) + ], + undated: [makeEntry({ precision: 'UNKNOWN', eventDate: undefined, documentId: 'u1' })] + }), + personId: undefined + }); + // Two year bands inside the
        , plus the separate undated section. + expect(document.querySelectorAll('ol section h2')).toHaveLength(2); + expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull(); + }); + + it('renders two derived events in one band without key collision (no-double-null-key)', () => { + const a = makeEntry({ + kind: 'EVENT', + derived: true, + derivedType: 'BIRTH', + title: 'Geburt: Anna', + senderName: '', + receiverName: '', + documentId: undefined, + eventId: undefined, + linkedPersonIds: ['p1'] + }); + const b = makeEntry({ + kind: 'EVENT', + derived: true, + derivedType: 'BIRTH', + title: 'Geburt: Bertha', + senderName: '', + receiverName: '', + documentId: undefined, + eventId: undefined, + linkedPersonIds: ['p2'] + }); + render(TimelineView, { timeline: makeTimelineDTO({ years: [makeYear(1915, [a, b])] }) }); + expect(document.body.textContent).toContain('Geburt: Anna'); + expect(document.body.textContent).toContain('Geburt: Bertha'); + }); + + it('shows the redundant non-color cue label for each layer (REQ-018)', () => { + render(TimelineView, { + timeline: makeTimelineDTO({ + years: [ + makeYear(1914, [ + makeEntry({ + kind: 'EVENT', + derived: true, + derivedType: 'BIRTH', + title: 'Geburt: Hans', + senderName: '', + receiverName: '', + documentId: undefined + }), + makeEntry({ + kind: 'EVENT', + derived: false, + type: 'PERSONAL', + eventId: 'e1', + title: 'Auswanderung', + senderName: '', + receiverName: '', + documentId: undefined + }), + makeEntry({ + kind: 'EVENT', + derived: false, + type: 'HISTORICAL', + precision: 'RANGE', + eventDate: '1914-01-01', + eventDateEnd: '1918-12-31', + title: 'Erster Weltkrieg', + senderName: '', + receiverName: '', + documentId: undefined + }) + ]) + ] + }) + }); + expect(document.body.textContent).toContain('Weltgeschehen'); + expect(document.body.textContent).toContain('Familie'); + expect(document.body.textContent).toContain('Geburt'); + }); + + it('places consecutive letter cards on alternating sides (REQ-004 surrogate)', () => { + const letters = Array.from({ length: 4 }, (_, i) => makeEntry({ documentId: `d${i}` })); + render(TimelineView, { timeline: makeTimelineDTO({ years: [makeYear(1909, letters)] }) }); + const sides = Array.from(document.querySelectorAll('.letter-row')).map((el) => + el.getAttribute('data-side') + ); + expect(sides).toEqual(['left', 'right', 'left', 'right']); + }); +}); -- 2.49.1 From dbef0e1e60c5dff66837936256e7faef58e3bfc6 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 19:44:23 +0200 Subject: [PATCH 13/22] fix(timeline): wrap long letter names/titles to avoid 320px overflow break-words on sender/receiver/title so a 25+char correspondent name cannot force horizontal overflow on a 320px phone (REQ-005). Refs #779 Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/timeline/LetterCard.svelte | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/timeline/LetterCard.svelte b/frontend/src/lib/timeline/LetterCard.svelte index f5f90302..b7e8362c 100644 --- a/frontend/src/lib/timeline/LetterCard.svelte +++ b/frontend/src/lib/timeline/LetterCard.svelte @@ -29,9 +29,11 @@ const receiver = $derived( class="rounded-sm border border-l-[3px] border-line border-l-brand-mint bg-surface px-3 py-2 shadow-sm transition-colors hover:border-brand-mint focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy" > {#if entry.title} - {entry.title} + {entry.title} {/if} - + {sender} {receiver} -- 2.49.1 From 6f3229925500f1d5be00e7828edd75735d5a25d7 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 19:49:15 +0200 Subject: [PATCH 14/22] feat(timeline): add /zeitstrahl route, SSR load, and nav link SSR-first load fetches GET /api/timeline via createApiClient (auth cookie forwarded), no query params for the global view (REQ-001), returns { timeline } with no client-side fetch (REQ-002); 401 -> /login, any other non-ok -> error(status, getErrorMessage(...)), never raw JSON, no PII logged (REQ-022). The page renders under the layout's
        . Adds the Zeitstrahl nav link (desktop + mobile) and 'timeline' to the eslint routes boundary allow-list so the route may import the domain. Refs #779 Co-Authored-By: Claude Opus 4.8 --- frontend/eslint.config.js | 1 + frontend/src/routes/AppNav.svelte | 20 ++++++ .../src/routes/zeitstrahl/+page.server.ts | 19 +++++ frontend/src/routes/zeitstrahl/+page.svelte | 16 +++++ .../src/routes/zeitstrahl/page.server.test.ts | 72 +++++++++++++++++++ 5 files changed, 128 insertions(+) create mode 100644 frontend/src/routes/zeitstrahl/+page.server.ts create mode 100644 frontend/src/routes/zeitstrahl/+page.svelte create mode 100644 frontend/src/routes/zeitstrahl/page.server.test.ts diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 40aee11b..b7d3975b 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -215,6 +215,7 @@ export default defineConfig( 'ocr', 'activity', 'conversation', + 'timeline', 'shared' ] } diff --git a/frontend/src/routes/AppNav.svelte b/frontend/src/routes/AppNav.svelte index 260c96c8..e70340b5 100644 --- a/frontend/src/routes/AppNav.svelte +++ b/frontend/src/routes/AppNav.svelte @@ -78,6 +78,16 @@ function handleOverlayKeydown(event: KeyboardEvent) { > {m.nav_geschichten()} + + + {m.nav_zeitstrahl()} + {#if isAdmin} + + {m.nav_zeitstrahl()} + + {#if isAdmin} +import * as m from '$lib/paraglide/messages.js'; +import TimelineView from '$lib/timeline/TimelineView.svelte'; +import type { PageData } from './$types'; + +let { data }: { data: PageData } = $props(); + + + + {m.timeline_heading()} + + +
        +

        {m.timeline_heading()}

        + +
        diff --git a/frontend/src/routes/zeitstrahl/page.server.test.ts b/frontend/src/routes/zeitstrahl/page.server.test.ts new file mode 100644 index 00000000..e7613eec --- /dev/null +++ b/frontend/src/routes/zeitstrahl/page.server.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('$lib/shared/api.server', () => ({ + createApiClient: vi.fn(), + extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code +})); + +import { load } from './+page.server'; +import { createApiClient } from '$lib/shared/api.server'; +import { getErrorMessage } from '$lib/shared/errors'; + +beforeEach(() => vi.clearAllMocks()); + +const TIMELINE = { years: [{ year: 1914, entries: [] }], undated: [] }; + +function mockApi(opts: { ok?: boolean; status?: number; data?: unknown; error?: unknown }) { + const { ok = true, status = 200, data = TIMELINE, error } = opts; + const GET = vi.fn().mockResolvedValue({ + response: { ok, status }, + data: ok ? data : undefined, + error + }); + vi.mocked(createApiClient).mockReturnValue({ GET } as unknown as ReturnType< + typeof createApiClient + >); + return GET; +} + +function callLoad() { + return load({ + fetch: vi.fn() as unknown as typeof fetch, + url: new URL('http://localhost/zeitstrahl'), + request: new Request('http://localhost/zeitstrahl'), + route: { id: '/zeitstrahl' }, + params: {} + } as unknown as Parameters[0]); +} + +describe('zeitstrahl +page.server load', () => { + it('fetches GET /api/timeline and returns { timeline } on ok (REQ-001/002)', async () => { + const GET = mockApi({ data: TIMELINE }); + const result = await callLoad(); + expect(GET).toHaveBeenCalledWith('/api/timeline'); + expect(result).toEqual({ timeline: TIMELINE }); + }); + + it('redirects to /login on 401 (REQ-022)', async () => { + mockApi({ ok: false, status: 401 }); + await expect(callLoad()).rejects.toMatchObject({ status: 302, location: '/login' }); + }); + + it('throws a mapped error on 404 (REQ-022)', async () => { + mockApi({ ok: false, status: 404, error: { code: 'TIMELINE_EVENT_NOT_FOUND' } }); + await expect(callLoad()).rejects.toMatchObject({ + status: 404, + body: { message: getErrorMessage('TIMELINE_EVENT_NOT_FOUND') } + }); + }); + + it('throws a mapped error on 500 (REQ-022)', async () => { + mockApi({ ok: false, status: 500, error: undefined }); + await expect(callLoad()).rejects.toMatchObject({ status: 500 }); + }); + + it('throws a mapped FORBIDDEN error on 403 (REQ-022)', async () => { + mockApi({ ok: false, status: 403, error: { code: 'FORBIDDEN' } }); + await expect(callLoad()).rejects.toMatchObject({ + status: 403, + body: { message: getErrorMessage('FORBIDDEN') } + }); + }); +}); -- 2.49.1 From 852fb71ee764711be42c29a1ec7ea48f7c15cfd7 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 19:54:07 +0200 Subject: [PATCH 15/22] test(timeline): add /zeitstrahl E2E spec Nav-link smoke + timeline-in-
        (empty-or-populated), and the 320px no-overflow guarantee on a timeline seeded with 25+char correspondent names (REQ-005). Runs against the real stack via the seeded admin session. Refs #779 Co-Authored-By: Claude Opus 4.8 --- frontend/e2e/zeitstrahl.spec.ts | 84 +++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 frontend/e2e/zeitstrahl.spec.ts diff --git a/frontend/e2e/zeitstrahl.spec.ts b/frontend/e2e/zeitstrahl.spec.ts new file mode 100644 index 00000000..18c633e0 --- /dev/null +++ b/frontend/e2e/zeitstrahl.spec.ts @@ -0,0 +1,84 @@ +import { test, expect, type APIRequestContext } from '@playwright/test'; + +/** + * Global /zeitstrahl timeline (#779). Runs against the real stack with the + * seeded admin session (auth.setup). Covers the primary journey (nav → page, + * timeline inside
        ) and the 320px no-overflow guarantee on a populated + * timeline seeded with 25+char correspondent names (REQ-005). + */ + +const stamp = () => new Date().toISOString().replace(/[^0-9]/g, ''); + +async function createPerson(request: APIRequestContext, firstName: string, lastName: string) { + const res = await request.post('/api/persons', { + data: { personType: 'PERSON', firstName, lastName } + }); + if (!res.ok()) throw new Error(`create person failed: ${res.status()}`); + return (await res.json()).id as string; +} + +/** Seeds one dated letter with long sender/receiver names so it lands on the timeline. */ +async function seedDatedLetter(request: APIRequestContext) { + const senderId = await createPerson( + request, + 'Friedrich-Wilhelm', + `Maximilian von Habsburg ${stamp()}` + ); + const receiverId = await createPerson( + request, + 'Maria-Magdalena', + `Hohenzollern-Sigmaringen ${stamp()}` + ); + + const createRes = await request.post('/api/documents', { + multipart: { title: `E2E Zeitstrahl Brief ${stamp()}` } + }); + if (!createRes.ok()) throw new Error(`create document failed: ${createRes.status()}`); + const docId = (await createRes.json()).id as string; + + const put = await request.put(`/api/documents/${docId}`, { + multipart: { + title: `E2E Zeitstrahl Brief ${stamp()}`, + documentDate: '1915-06-15', + metaDatePrecision: 'DAY', + senderId, + receiverIds: receiverId + } + }); + if (!put.ok()) throw new Error(`update document failed: ${put.status()}`); +} + +test.describe('Zeitstrahl — global timeline (#779)', () => { + test('nav link opens /zeitstrahl and the timeline lives in
        ', async ({ page }) => { + await page.goto('/'); + await page.getByRole('navigation').getByRole('link', { name: 'Zeitstrahl' }).first().click(); + await expect(page).toHaveURL(/\/zeitstrahl$/); + await expect(page.getByRole('heading', { level: 1, name: 'Zeitstrahl' })).toBeVisible(); + + // The main landmark contains either the populated
          or the empty state. + const main = page.getByRole('main'); + const ol = main.locator('ol'); + const empty = main.getByText('Noch keine Ereignisse.'); + await expect(async () => { + const populated = (await ol.count()) > 0; + const isEmpty = await empty.isVisible().catch(() => false); + expect(populated || isEmpty).toBe(true); + }).toPass(); + }); + + test('no horizontal overflow at 320px with long correspondent names (REQ-005)', async ({ + page, + request + }) => { + await seedDatedLetter(request); + + await page.setViewportSize({ width: 320, height: 900 }); + await page.goto('/zeitstrahl'); + + // Populated: the seeded letter puts the timeline
            in the DOM. + await expect(page.getByRole('main').locator('ol')).toHaveCount(1); + + const scrollWidth = await page.evaluate(() => document.body.scrollWidth); + expect(scrollWidth).toBe(320); + }); +}); -- 2.49.1 From b501592156002aa2099352238d04f263c6391e34 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 19:59:41 +0200 Subject: [PATCH 16/22] docs(timeline): reword LetterCard comment so the REQ-021 @html grep is zero Refs #779 Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/timeline/LetterCard.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/timeline/LetterCard.svelte b/frontend/src/lib/timeline/LetterCard.svelte index b7e8362c..11a1b787 100644 --- a/frontend/src/lib/timeline/LetterCard.svelte +++ b/frontend/src/lib/timeline/LetterCard.svelte @@ -9,7 +9,7 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO']; * A single archive letter on the timeline: sender → receiver, title, and a * precision-aware date chip, linking to the document. Names/titles are * OCR/import-derived — rendered via default `{...}` escaping with - * `whitespace-pre-line` for line breaks (REQ-021); never `{@html}`. + * `whitespace-pre-line` for line breaks (REQ-021); never the raw-HTML directive. */ let { entry }: { entry: TimelineEntryDTO } = $props(); -- 2.49.1 From fee519b8a95a80c061f476280cfdcb24f5501e61 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 20:00:00 +0200 Subject: [PATCH 17/22] docs(timeline): document /zeitstrahl, lib/timeline, monthBuckets move; RTM #779 Route tables (CLAUDE.md + frontend/CLAUDE.md), the document/timeline.ts -> $lib/shared/utils/monthBuckets move (document + shared READMEs), GLOSSARY Lebensweg entry, the c4 l3-frontend people-stories diagram, and the RTM rows REQ-001..027 for feature zeitstrahl-global-view (#779), all marked Done. Refs #779 Co-Authored-By: Claude Opus 4.8 --- .specify/rtm.md | 27 ++++++++++++++++ CLAUDE.md | 1 + docs/GLOSSARY.md | 2 ++ .../c4/l3-frontend-3c-people-stories.puml | 3 ++ frontend/CLAUDE.md | 6 ++-- frontend/src/lib/document/README.md | 1 + frontend/src/lib/shared/README.md | 32 ++++++++++--------- 7 files changed, 55 insertions(+), 17 deletions(-) diff --git a/.specify/rtm.md b/.specify/rtm.md index a8a79d6c..827546fa 100644 --- a/.specify/rtm.md +++ b/.specify/rtm.md @@ -79,3 +79,30 @@ | 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
              ` | 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 `
                ` chronological; each band a `
                ` with sticky `

                ` 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
                  `, `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 German-only across locales) | #779 | zeitstrahl-global-view | `frontend/messages/{de,en,es}.json` | `eventCardConfig.spec.ts` (German labels), 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 | diff --git a/CLAUDE.md b/CLAUDE.md index 8fbfe119..19998f96 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -207,6 +207,7 @@ frontend/src/routes/ ├── aktivitaeten/ Unified activity feed (Chronik) ├── geschichten/ Stories — list, [id], [id]/edit, new ├── stammbaum/ Family tree (Stammbaum) +├── zeitstrahl/ Global timeline (Zeitstrahl) — life-events + events + letters woven in time; SSR-loads GET /api/timeline, renders lib/timeline/TimelineView (Datum mode) ├── themen/ Topics directory — browsable tag index ├── enrich/ Enrichment workflow — [id], done ├── admin/ User, group, tag, OCR, system management diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 29cc18e1..bc6dabac 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -170,6 +170,8 @@ _Not to be confused with a document item's optional note_ — a document item's **Zeitstrahl** `[user-facing]` — the family timeline view, rendering curated `TimelineEvent`s and derived life-events chronologically. The milestone home of the `timeline` domain. +**Lebensweg** `[user-facing]` — the per-person variant of the *Zeitstrahl*: the same `TimelineView` component, scoped to a single person via a `personId` prop, rendering that person's life-events, events, and letters as a left-anchored rail. The global Zeitstrahl is the `personId`-undefined case of the same component (issue #10 wires the per-person rail; the prop seam ships with the global view). + **derived event** — a timeline entry computed on-read from curated `Person` or `PersonRelationship` data, never persisted. Carried as a `TimelineEntryDTO` with `derived=true` and a non-null `DerivedEventType`. Three subtypes: Geburt (birth, from `Person.birthDate`), Tod (death, from `Person.deathDate`), Heirat (marriage, from a `SPOUSE_OF` `PersonRelationship` edge). Callers of `assembleDerivedEvents()` are responsible for enforcing `READ_ALL` authorization before invoking it (ADR-043). _Not to be confused with a `TimelineEvent`_ — a `TimelineEvent` is a curated record authored by a human and stored in `timeline_events`; a derived event is computed on-the-fly and never written to the database. diff --git a/docs/architecture/c4/l3-frontend-3c-people-stories.puml b/docs/architecture/c4/l3-frontend-3c-people-stories.puml index cf424d63..6ba983ff 100644 --- a/docs/architecture/c4/l3-frontend-3c-people-stories.puml +++ b/docs/architecture/c4/l3-frontend-3c-people-stories.puml @@ -14,6 +14,7 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") { Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story/Journey list and detail pages. List: GeschichteListRow with REISE badge for JOURNEY type. Detail: dispatches to StoryReader (rich text + persons) or JourneyReader (intro + ordered JourneyItemCard/JourneyInterlude items + empty state) based on GeschichteType. BLOG_WRITE users see edit/delete actions. Loader: GET /api/geschichten, GET /api/geschichten/{id}.") Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor and creation flow. New: TypeSelector (STORY/JOURNEY radio group with roving tabindex) → StoryCreate (TipTap rich text, person linking, POST /api/geschichten) or JourneyCreate (title + first item). Edit: branches on GeschichteType — STORY opens GeschichteEditor (TipTap body + GeschichteSidebar incl. StoryDocumentPanel: document linking via POST/DELETE /items); JOURNEY opens JourneyEditor (title, intro textarea, ordered JourneyItemRow list with drag-reorder + move-up/down, JourneyAddBar for document/interlude addition, GeschichteSidebar). JourneyEditor mutations: POST/DELETE /items, PUT /items/reorder, PATCH /items/{id}. Requires BLOG_WRITE permission.") Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.") + Component(zeitstrahl, "/zeitstrahl", "SvelteKit Route", "Global timeline (Zeitstrahl). SSR loader: GET /api/timeline -> TimelineDTO. Renders lib/timeline/TimelineView (Datum mode): year bands (YearBand) with EventPill / WorldBand / LetterCard, dense-year YearLetterStrip (shared Sparkline + monthHistogram), folded GapSpan for empty-year runs, and an undated bucket. personId prop is the per-person Lebensweg seam (issue #10), undefined here.") Component(themen, "/themen", "SvelteKit Route", "Browsable topic index. Shows all root tags as cards with color bars and child rows. ThemenWidget also embedded in the home dashboard (reader + editor sidebar). Loader: GET /api/tags/tree.") Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.") Component(userProfile, "/users/[id]", "SvelteKit Route", "Public user profile view. Loader: GET /api/users/{id}.") @@ -27,6 +28,8 @@ Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications" Rel(geschichten, backend, "GET /api/geschichten, GET /api/geschichten/{id}, DELETE /api/geschichten/{id}", "HTTP / JSON") Rel(geschichtenEdit, backend, "GET /api/persons/{id} (pre-populate), POST /api/geschichten, PUT /api/geschichten/{id}, POST/DELETE /api/geschichten/{id}/items, PUT /api/geschichten/{id}/items/reorder, PATCH /api/geschichten/{id}/items/{itemId}", "HTTP / JSON") Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON") +Rel(user, zeitstrahl, "Reads the family timeline", "HTTPS / Browser") +Rel(zeitstrahl, backend, "GET /api/timeline -> TimelineDTO", "HTTP / JSON") Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON") Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON") Rel(userProfile, backend, "GET /api/users/{id}", "HTTP / JSON") diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index a6fa8df7..3d367b4d 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -34,6 +34,7 @@ src/ │ ├── api/ # Internal API proxies (server-side only) │ ├── geschichten/ # Stories (list, [id], [id]/edit, new) │ ├── stammbaum/ # Family tree +│ ├── zeitstrahl/ # Global timeline (Zeitstrahl) — SSR loads /api/timeline, renders lib/timeline │ ├── enrich/ # Enrichment workflow ([id], done) │ ├── hilfe/transkription/ # Transcription help page │ ├── profile/ # User profile settings @@ -49,6 +50,7 @@ src/ │ │ ├── relationship/ # Relationship form + chip components │ │ └── genealogy/ # Stammbaum (family tree) components │ ├── tag/ # Tag domain: TagInput, TagChipList, TagParentPicker +│ ├── timeline/ # Timeline (Zeitstrahl) domain: TimelineView, YearBand, EventPill, WorldBand, LetterCard, YearLetterStrip, GapSpan; dateLabel + timelineDensity + eventCardConfig (imports $lib/shared only, never document/) │ ├── geschichte/ # Geschichte (story) domain: editor + card │ ├── notification/ # Notification bell + dropdown + store │ ├── activity/ # Activity feed (Chronik) components @@ -59,8 +61,8 @@ src/ │ │ ├── hooks/ # Reusable Svelte state hooks (useTypeahead, etc.) │ │ ├── server/ # Server-only utilities (locale, session) │ │ ├── services/ # Client-side service helpers -│ │ ├── utils/ # Pure utility functions (date, search, etc.) -│ │ ├── primitives/ # Generic UI primitives (BackButton, ProgressRing, etc.) +│ │ ├── utils/ # Pure utility functions (date, search, monthBuckets — month-bucket math shared by document chart + timeline strip) +│ │ ├── primitives/ # Generic UI primitives (BackButton, ProgressRing, Sparkline, etc.) │ │ ├── dashboard/ # Dashboard stat components │ │ ├── discussion/ # CommentThread + shared discussion UI │ │ ├── help/ # Help/FAQ page components diff --git a/frontend/src/lib/document/README.md b/frontend/src/lib/document/README.md index 16885de7..b0485d76 100644 --- a/frontend/src/lib/document/README.md +++ b/frontend/src/lib/document/README.md @@ -30,6 +30,7 @@ Sub-folders: `annotation/`, `transcription/`, `viewer/`. - `tag/TagInput.svelte` — tag chip input - `ocr/OcrProgress.svelte` — job status indicator in the document header - `shared/primitives/BackButton.svelte`, `shared/discussion/` — shared UI +- `shared/utils/monthBuckets.ts` — the density chart's pure month-bucket math (boundaries, gap-fill, year aggregation, axis ticks) now lives in `shared/` so the `timeline/` domain can reuse it; `document/timeline.ts` keeps only the `/api/documents/density` glue (`fetchDensity`, `buildDensityUrl`) ## Backend counterpart diff --git a/frontend/src/lib/shared/README.md b/frontend/src/lib/shared/README.md index 5968ed4a..07c77df6 100644 --- a/frontend/src/lib/shared/README.md +++ b/frontend/src/lib/shared/README.md @@ -14,21 +14,23 @@ If any condition fails, the file belongs in the domain folder of its primary con ## What this folder owns -| Sub-folder / file | Purpose | -| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `api.server.ts` | Typed `openapi-fetch` client factory — the standard entry point for all backend API calls in server-side load functions and actions | -| `errors.ts` | Mirror of the backend `ErrorCode` enum + `getErrorMessage()` → Paraglide i18n key mapping | -| `types.ts` | Cross-domain TypeScript interfaces | -| `utils.ts` | Pure utility functions (date formatting, sorting, debounce) | -| `relativeTime.ts` | Human-relative time formatting (`"2 days ago"`) | -| `primitives/` | Generic UI components: `BackButton.svelte`, form inputs, pagination, layout shells | -| `discussion/` | Comment/mention editor shared by `document/` and `geschichte/` | -| `dashboard/` | Family Pulse widget and recent-activity components assembled in the `/` route | -| `hooks/` | Svelte 5 reactive hooks: `useTypeahead`, `useUnsavedWarning` | -| `services/` | Generic client-side service helpers | -| `actions/` | Shared SvelteKit form action utilities | -| `server/` | Server-only shared utilities (load function helpers) | -| `help/` | Coach marks and empty-state components used across multiple domains | +| Sub-folder / file | Purpose | +| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `api.server.ts` | Typed `openapi-fetch` client factory — the standard entry point for all backend API calls in server-side load functions and actions | +| `errors.ts` | Mirror of the backend `ErrorCode` enum + `getErrorMessage()` → Paraglide i18n key mapping | +| `types.ts` | Cross-domain TypeScript interfaces | +| `utils.ts` | Pure utility functions (date formatting, sorting, debounce) | +| `utils/monthBuckets.ts` | Pure month-bucket math (boundaries, sequences, gap-fill, year aggregation, axis ticks) shared by the `document/` density chart and the `timeline/` density strip — moved up from `document/timeline.ts` so `timeline/` need not import `document/` | +| `primitives/Sparkline.svelte` | Fixed-series bar sparkline (one bar per value) — used by the timeline density strip | +| `relativeTime.ts` | Human-relative time formatting (`"2 days ago"`) | +| `primitives/` | Generic UI components: `BackButton.svelte`, form inputs, pagination, layout shells | +| `discussion/` | Comment/mention editor shared by `document/` and `geschichte/` | +| `dashboard/` | Family Pulse widget and recent-activity components assembled in the `/` route | +| `hooks/` | Svelte 5 reactive hooks: `useTypeahead`, `useUnsavedWarning` | +| `services/` | Generic client-side service helpers | +| `actions/` | Shared SvelteKit form action utilities | +| `server/` | Server-only shared utilities (load function helpers) | +| `help/` | Coach marks and empty-state components used across multiple domains | ## What does NOT belong here -- 2.49.1 From 7902f4e6acf78b1f1f808a01c9485c2c83613388 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 20:30:59 +0200 Subject: [PATCH 18/22] refactor(timeline): extract entryKey helper from YearBand Move the per-entry {#each} key logic into a shared entryKey.ts so the undated bucket in TimelineView can reuse it. No behavior change. Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/timeline/YearBand.svelte | 11 +---------- frontend/src/lib/timeline/entryKey.ts | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 10 deletions(-) create mode 100644 frontend/src/lib/timeline/entryKey.ts diff --git a/frontend/src/lib/timeline/YearBand.svelte b/frontend/src/lib/timeline/YearBand.svelte index 005e6e7e..a612a750 100644 --- a/frontend/src/lib/timeline/YearBand.svelte +++ b/frontend/src/lib/timeline/YearBand.svelte @@ -4,6 +4,7 @@ import WorldBand from './WorldBand.svelte'; import LetterCard from './LetterCard.svelte'; import YearLetterStrip from './YearLetterStrip.svelte'; import { isDense } from './timelineDensity'; +import { entryKey } from './entryKey'; import type { components } from '$lib/generated/api'; type TimelineYearDTO = components['schemas']['TimelineYearDTO']; @@ -42,16 +43,6 @@ const rows = $derived.by(() => { } return out; }); - -function entryKey(entry: TimelineEntryDTO): string { - return ( - entry.kind + - ':' + - (entry.eventId ?? - entry.documentId ?? - `${entry.derivedType}:${(entry.linkedPersonIds ?? []).join('-')}`) - ); -}
                  diff --git a/frontend/src/lib/timeline/entryKey.ts b/frontend/src/lib/timeline/entryKey.ts new file mode 100644 index 00000000..4ebd74de --- /dev/null +++ b/frontend/src/lib/timeline/entryKey.ts @@ -0,0 +1,23 @@ +import type { components } from '$lib/generated/api'; + +type TimelineEntryDTO = components['schemas']['TimelineEntryDTO']; + +/** + * Stable `{#each}` key for a timeline entry. Prefers the entry's own identity + * (`eventId` for curated events, `documentId` for letters); derived life-events + * carry neither, so they key on `derivedType` + their linked person ids — which + * keeps two derived births in the same year distinct. The `kind` prefix keeps an + * event and a letter that happen to share an id from colliding. + * + * Used by both `YearBand` (per-band rows) and `TimelineView` (the undated + * bucket), where entries can be events without a `documentId`. + */ +export function entryKey(entry: TimelineEntryDTO): string { + return ( + entry.kind + + ':' + + (entry.eventId ?? + entry.documentId ?? + `${entry.derivedType}:${(entry.linkedPersonIds ?? []).join('-')}`) + ); +} -- 2.49.1 From 732651959e0a54fcd8fe9a62c80c7453c46a2fc2 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 20:32:32 +0200 Subject: [PATCH 19/22] fix(timeline): render undated events as pills/bands, not letter cards The undated bucket is assembled from all entries, so it can contain events as well as letters. Rendering every undated entry with LetterCard produced a dead /documents/undefined link and "Unknown -> Unknown" for events. Dispatch on kind/type like YearBand does (WorldBand/EventPill/ LetterCard). Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/timeline/TimelineView.svelte | 21 +++++- .../lib/timeline/TimelineView.svelte.spec.ts | 64 +++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/timeline/TimelineView.svelte b/frontend/src/lib/timeline/TimelineView.svelte index e1732816..2eef69f3 100644 --- a/frontend/src/lib/timeline/TimelineView.svelte +++ b/frontend/src/lib/timeline/TimelineView.svelte @@ -3,6 +3,9 @@ import * as m from '$lib/paraglide/messages.js'; import YearBand from './YearBand.svelte'; import GapSpan from './GapSpan.svelte'; import LetterCard from './LetterCard.svelte'; +import EventPill from './EventPill.svelte'; +import WorldBand from './WorldBand.svelte'; +import { entryKey } from './entryKey'; import type { components } from '$lib/generated/api'; type TimelineDTO = components['schemas']['TimelineDTO']; @@ -59,8 +62,22 @@ const isEmpty = $derived(timeline.years.length === 0 && timeline.undated.length

                  {m.timeline_undated_section()}

                    - {#each timeline.undated as entry (entry.documentId ?? entry.title)} -
                  • + + {#each timeline.undated as entry (entryKey(entry))} +
                  • + {#if entry.kind === 'EVENT'} + {#if entry.type === 'HISTORICAL'} + + {:else} + + {/if} + {:else} + + {/if} +
                  • {/each}
                  diff --git a/frontend/src/lib/timeline/TimelineView.svelte.spec.ts b/frontend/src/lib/timeline/TimelineView.svelte.spec.ts index 6d6422b3..ec878a5a 100644 --- a/frontend/src/lib/timeline/TimelineView.svelte.spec.ts +++ b/frontend/src/lib/timeline/TimelineView.svelte.spec.ts @@ -89,6 +89,70 @@ describe('TimelineView', () => { expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull(); }); + it('renders an undated curated EVENT as a pill, not a broken letter card (REQ-008/016)', () => { + render(TimelineView, { + timeline: makeTimelineDTO({ + undated: [ + makeEntry({ + kind: 'EVENT', + type: 'PERSONAL', + derived: false, + eventId: 'e1', + precision: 'UNKNOWN', + eventDate: undefined, + title: 'Auswanderung', + senderName: '', + receiverName: '', + documentId: undefined + }) + ] + }) + }); + // The event renders inside the undated section… + expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull(); + expect(document.body.textContent).toContain('Auswanderung'); + // …as an EventPill (its edit affordance), never as a letter card linking + // to /documents/undefined with "Unbekannt → Unbekannt". + expect(document.querySelector('[data-testid="event-edit"]')).not.toBeNull(); + expect(document.querySelector('a[href="/documents/undefined"]')).toBeNull(); + expect(document.body.textContent).not.toContain('Unbekannt'); + }); + + it('renders an undated HISTORICAL EVENT as a world band, not a letter card (REQ-009/016)', () => { + render(TimelineView, { + timeline: makeTimelineDTO({ + undated: [ + makeEntry({ + kind: 'EVENT', + type: 'HISTORICAL', + derived: false, + precision: 'UNKNOWN', + eventDate: undefined, + title: 'Weltwirtschaftskrise', + senderName: '', + receiverName: '', + documentId: undefined + }) + ] + }) + }); + expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull(); + expect(document.body.textContent).toContain('Weltwirtschaftskrise'); + // HISTORICAL → WorldBand carries the sr-only "Weltgeschehen" cue (REQ-018), + // not a broken document link. + expect(document.body.textContent).toContain('Weltgeschehen'); + expect(document.querySelector('a[href="/documents/undefined"]')).toBeNull(); + }); + + it('still renders an undated LETTER as a letter card (REQ-016)', () => { + render(TimelineView, { + timeline: makeTimelineDTO({ + undated: [makeEntry({ precision: 'UNKNOWN', eventDate: undefined, documentId: 'u1' })] + }) + }); + expect(document.querySelector('a[href="/documents/u1"]')).not.toBeNull(); + }); + it('renders two derived events in one band without key collision (no-double-null-key)', () => { const a = makeEntry({ kind: 'EVENT', -- 2.49.1 From 4a6fd770d789687a6d1d286376ea6a68b925346f Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 20:34:54 +0200 Subject: [PATCH 20/22] fix(i18n): translate timeline sr-only labels in en/es locales timeline_layer_* and timeline_derived_* shipped German values in the English and Spanish catalogs, so EN/ES screen-reader users heard German for the world/family layer and birth/death/marriage cues. Translate them; de.json stays canonical. Co-Authored-By: Claude Opus 4.8 --- frontend/messages/en.json | 10 +++++----- frontend/messages/es.json | 10 +++++----- frontend/src/lib/timeline/eventCardConfig.ts | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/frontend/messages/en.json b/frontend/messages/en.json index c8255685..234437ad 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1041,11 +1041,11 @@ "timeline_letters_count": "{count} letters", "timeline_strip_expand": "Show letters", "timeline_range_aria": "Period: {from} to {to}", - "timeline_layer_world": "Weltgeschehen", - "timeline_layer_family": "Familie", - "timeline_derived_birth": "Geburt", - "timeline_derived_death": "Tod", - "timeline_derived_marriage": "Heirat", + "timeline_layer_world": "World events", + "timeline_layer_family": "Family", + "timeline_derived_birth": "Birth", + "timeline_derived_death": "Death", + "timeline_derived_marriage": "Marriage", "error_geschichte_not_found": "The story was not found.", "error_journey_item_not_found": "The journey item was not found.", "error_journey_item_position_conflict": "The order was just changed by someone else — please reload the page.", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 746c554a..aab54c4d 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1041,11 +1041,11 @@ "timeline_letters_count": "{count} cartas", "timeline_strip_expand": "Mostrar cartas", "timeline_range_aria": "Período: {from} a {to}", - "timeline_layer_world": "Weltgeschehen", - "timeline_layer_family": "Familie", - "timeline_derived_birth": "Geburt", - "timeline_derived_death": "Tod", - "timeline_derived_marriage": "Heirat", + "timeline_layer_world": "Acontecimientos mundiales", + "timeline_layer_family": "Familia", + "timeline_derived_birth": "Nacimiento", + "timeline_derived_death": "Fallecimiento", + "timeline_derived_marriage": "Matrimonio", "error_geschichte_not_found": "No se encontró la historia.", "error_journey_item_not_found": "No se encontró el elemento del viaje.", "error_journey_item_position_conflict": "El orden fue cambiado por otra persona — por favor recargue la página.", diff --git a/frontend/src/lib/timeline/eventCardConfig.ts b/frontend/src/lib/timeline/eventCardConfig.ts index 715249b3..cb11d7b2 100644 --- a/frontend/src/lib/timeline/eventCardConfig.ts +++ b/frontend/src/lib/timeline/eventCardConfig.ts @@ -9,7 +9,7 @@ export type TimelineAccent = 'derived' | 'curated' | 'historical'; export interface AccentConfig { /** Visible Unicode glyph — render `aria-hidden`, paired with an sr-only label. */ glyph: string; - /** German layer/life-event label — used as the sr-only text and as visible chrome. */ + /** Localized layer/life-event label — used as the sr-only / aria text only. */ label: string; accent: TimelineAccent; } -- 2.49.1 From ce1b4c748e1118689cfa0ba99019163a2802cb98 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 21:30:02 +0200 Subject: [PATCH 21/22] test(i18n): pin localized timeline layer/derived labels per locale REQ-024 was updated (issue #779) to require localized sr-only/aria labels instead of German-only. Pin the de/en/es values so they cannot silently drift back to the German source strings. Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/messages.spec.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/frontend/src/lib/messages.spec.ts b/frontend/src/lib/messages.spec.ts index e6d0b96c..95338e43 100644 --- a/frontend/src/lib/messages.spec.ts +++ b/frontend/src/lib/messages.spec.ts @@ -40,4 +40,32 @@ describe('message key parity', () => { expect(es).toHaveProperty('layout_menu_open'); expect(es).toHaveProperty('layout_menu_close'); }); + + // REQ-024: the timeline layer/life-event labels feed sr-only / aria text, so + // they are localized per locale (the original German-only MVP decision was + // reversed for accessibility). Pin the values so en/es can never silently + // drift back to the German source strings. + it('timeline layer/derived labels are localized per locale (REQ-024)', () => { + expect(de).toMatchObject({ + timeline_layer_world: 'Weltgeschehen', + timeline_layer_family: 'Familie', + timeline_derived_birth: 'Geburt', + timeline_derived_death: 'Tod', + timeline_derived_marriage: 'Heirat' + }); + expect(en).toMatchObject({ + timeline_layer_world: 'World events', + timeline_layer_family: 'Family', + timeline_derived_birth: 'Birth', + timeline_derived_death: 'Death', + timeline_derived_marriage: 'Marriage' + }); + expect(es).toMatchObject({ + timeline_layer_world: 'Acontecimientos mundiales', + timeline_layer_family: 'Familia', + timeline_derived_birth: 'Nacimiento', + timeline_derived_death: 'Fallecimiento', + timeline_derived_marriage: 'Matrimonio' + }); + }); }); -- 2.49.1 From d3f93c556ac6546747cf55ef144ff94fd5b41630 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 21:30:47 +0200 Subject: [PATCH 22/22] docs(rtm): REQ-024 now localized per locale, point at messages.spec pin Co-Authored-By: Claude Opus 4.8 --- .specify/rtm.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.specify/rtm.md b/.specify/rtm.md index 827546fa..12b8a524 100644 --- a/.specify/rtm.md +++ b/.specify/rtm.md @@ -102,7 +102,7 @@ | 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 German-only across locales) | #779 | zeitstrahl-global-view | `frontend/messages/{de,en,es}.json` | `eventCardConfig.spec.ts` (German labels), Paraglide compile | 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 | -- 2.49.1