From e613a932130074e4f83a880ff9556822c7323c4d Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 10:25:00 +0200 Subject: [PATCH 01/25] feat(timeline): compute a letter's linkedEventId in the timeline DTO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a nullable linkedEventId to TimelineEntryDTO — the curated event whose documents set contains the letter — resolved in one batched membership pass over the already-loaded events (no per-letter query, no new column). This is the single backend field the #827 Ereignis grouping mode consumes. Refs #827 Co-Authored-By: Claude Opus 4.8 --- .../timeline/TimelineEntryDTO.java | 10 ++++- .../timeline/TimelineEventService.java | 6 +-- .../timeline/TimelineService.java | 44 +++++++++++++++++-- .../timeline/TimelineServiceTest.java | 44 +++++++++++++++++-- 4 files changed, 93 insertions(+), 11 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEntryDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEntryDTO.java index 0739cbfb..6d5c5900 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEntryDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEntryDTO.java @@ -28,6 +28,13 @@ import java.util.UUID; * They are deliberately NOT {@code @Schema(requiredMode = REQUIRED)} so the generated TypeScript * types stay optional. * + *

Letter→event link ({@code linkedEventId}): for a {@link Kind#LETTER} entry, the id of + * the curated {@link TimelineEvent} whose {@code documents} set contains the letter's document, or + * {@code null} when the letter is referenced by no curated event (#827). Computed on read from the + * existing {@code timeline_event_documents} join — no new column. {@code null} for non-letter + * entries. Deliberately NOT {@code @Schema(requiredMode = REQUIRED)} so the generated TypeScript + * type stays optional. + * *

Callers of {@code TimelineEventService.assembleDerivedEvents()} must independently enforce * {@code READ_ALL} authorization before invoking that method (see ADR-043). */ @@ -47,6 +54,7 @@ public record TimelineEntryDTO( DerivedEventType derivedType, UUID rootTagId, String rootTagName, - String rootTagColor + String rootTagColor, + UUID linkedEventId ) { } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java index f2ee6d7e..03092647 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java @@ -267,7 +267,7 @@ public class TimelineEventService { p.getBirthDate(), null, p.getDisplayName(), EventType.PERSONAL, null, null, List.of(p.getId()), DerivedEventType.BIRTH, - null, null, null)) + null, null, null, null)) .toList(); } @@ -279,7 +279,7 @@ public class TimelineEventService { p.getDeathDate(), null, p.getDisplayName(), EventType.PERSONAL, null, null, List.of(p.getId()), DerivedEventType.DEATH, - null, null, null)) + null, null, null, null)) .toList(); } @@ -304,7 +304,7 @@ public class TimelineEventService { null, null, List.of(r.getPerson().getId(), r.getRelatedPerson().getId()), DerivedEventType.MARRIAGE, - null, null, null)); + null, null, null, null)); } } return result; diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java index 7a084205..b63d9bb8 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java @@ -80,9 +80,14 @@ public class TimelineService { // Resolve generation person IDs once — used across all three layers Set genPersonIds = resolveGenerationPersonIds(filter.generation()); + // Fetch curated events once — reused for both the event entries below and the + // batched letter→event link resolution (resolveLetterEventLinks), so the + // membership pass costs no extra query. REQ-005. + List allEvents = eventRepository.findAll(); + // ── curated events ─────────────────────────────────────────────────── List entries = new ArrayList<>(); - for (TimelineEvent ev : eventRepository.findAll()) { + for (TimelineEvent ev : allEvents) { if (!passesTypeFilter(ev.getType(), filter.type())) continue; if (!passesPersonFilter(ev.getPersons(), filter.personId())) continue; if (!passesGenerationFilter(ev.getPersons(), genPersonIds)) continue; @@ -107,8 +112,9 @@ public class TimelineService { letters.add(doc); } Map rootByDocId = resolveLetterRootTags(letters); + Map eventByDocId = resolveLetterEventLinks(letters, allEvents); for (Document doc : letters) { - entries.add(mapDocument(doc, rootByDocId)); + entries.add(mapDocument(doc, rootByDocId, eventByDocId)); } return bucket(entries); @@ -229,11 +235,13 @@ public class TimelineService { null, null, null, + null, null ); } - private TimelineEntryDTO mapDocument(Document doc, Map rootByDocId) { + private TimelineEntryDTO mapDocument(Document doc, Map rootByDocId, + Map eventByDocId) { RootTag root = rootByDocId.get(doc.getId()); return new TimelineEntryDTO( Kind.LETTER, @@ -251,10 +259,38 @@ public class TimelineService { null, root == null ? null : root.id(), root == null ? null : root.name(), - root == null ? null : root.color() + root == null ? null : root.color(), + eventByDocId.get(doc.getId()) ); } + /** + * Resolves each letter's linked curated event in one batched pass, keyed by document id: the + * event whose {@code documents} set contains the letter (REQ-005). A single doc→event map is + * built over the already-loaded events — no per-letter query ({@code TimelineEvent.documents} + * carries {@code @BatchSize(50)}). When a document is referenced by more than one curated + * event, the first by repository iteration order wins ({@code putIfAbsent}). The map is built + * from all events (not just the year/type-filtered ones) so the link is a stable + * property of the data; the frontend's filter-then-group decides whether the linked event is + * actually on screen (#827). Logs nothing — the assembly path keeps UUIDs out of logs (§2.7). + */ + private Map resolveLetterEventLinks(List letters, List events) { + Set letterDocIds = letters.stream().map(Document::getId).collect(Collectors.toSet()); + if (letterDocIds.isEmpty()) return Map.of(); + + Map eventByDocId = new HashMap<>(); + for (TimelineEvent ev : events) { + Set linkedDocs = ev.getDocuments(); + if (linkedDocs == null) continue; + for (Document linked : linkedDocs) { + if (letterDocIds.contains(linked.getId())) { + eventByDocId.putIfAbsent(linked.getId(), ev.getId()); + } + } + } + return eventByDocId; + } + /** * Resolves each letter's primary root tag in one batched pass, keyed by document id — no * per-letter N+1: each letter contributes only its alphabetically-first assigned tag (#835), diff --git a/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceTest.java index 06255ecb..7b2597f6 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceTest.java @@ -69,10 +69,10 @@ class TimelineServiceTest { UUID id2 = UUID.fromString("00000000-0000-0000-0000-000000000002"); var e1 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "", LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id1, List.of(), null, - null, null, null); + null, null, null, null); var e2 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "", LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id2, List.of(), null, - null, null, null); + null, null, null, null); var sorted = List.of(e2, e1).stream() .sorted(TimelineService.WITHIN_BAND_ORDER) @@ -511,6 +511,44 @@ class TimelineServiceTest { verify(tagService, times(1)).resolveRootTags(anyList()); } + // ─── letter→event link (#827, REQ-005/006) ─────────────────────────────── + + @Test + void letter_in_a_curated_events_documents_carries_that_events_id() { + // REQ-005: linkedEventId = the curated event whose documents set contains the letter. + Document letterDoc = docWithDate(LocalDate.of(1915, 5, 1), DatePrecision.MONTH); + UUID eventId = UUID.randomUUID(); + TimelineEvent event = TimelineEvent.builder().id(eventId) + .title("Briefe von der Front").type(EventType.PERSONAL) + .documents(new HashSet<>(Set.of(letterDoc))) + .build(); // no eventDate → event lands undated, leaving the year band to the letter + when(eventRepository.findAll()).thenReturn(List.of(event)); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of(letterDoc)); + + TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters())); + + assertThat(entry.linkedEventId()).isEqualTo(eventId); + } + + @Test + void letter_in_no_curated_event_has_null_linkedEventId() { + // REQ-006: a letter referenced by no curated event → linkedEventId null (frontend falls + // back to the per-year "Weitere Briefe" bucket). + Document letterDoc = docWithDate(LocalDate.of(1915, 5, 1), DatePrecision.MONTH); + TimelineEvent event = TimelineEvent.builder().id(UUID.randomUUID()) + .title("Anderes Ereignis").type(EventType.PERSONAL) + .documents(new HashSet<>(Set.of(Document.builder().id(UUID.randomUUID()).build()))) + .build(); + when(eventRepository.findAll()).thenReturn(List.of(event)); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of(letterDoc)); + + TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters())); + + assertThat(entry.linkedEventId()).isNull(); + } + private static TimelineEntryDTO onlyLetterEntry(TimelineDTO result) { assertThat(result.years()).hasSize(1); return result.years().get(0).entries().get(0); @@ -523,7 +561,7 @@ class TimelineServiceTest { private static TimelineEntryDTO letter(LocalDate date, DatePrecision precision, String title) { return new TimelineEntryDTO(Kind.LETTER, precision, false, "", "", date, null, title, null, null, UUID.randomUUID(), List.of(), null, - null, null, null); + null, null, null, null); } private static Document docWithDate(LocalDate date, DatePrecision precision) { -- 2.49.1 From 0726226c959305bee9272927b5feeaa532d9bc5b Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 10:27:57 +0200 Subject: [PATCH 02/25] chore(api): regenerate types with TimelineEntryDTO.linkedEventId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regenerated frontend/src/lib/generated/api.ts from the live OpenAPI spec after adding the nullable linkedEventId field — keeps the CI type-check green for the #827 grouping UI that consumes it. Refs #827 Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/generated/api.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 33f9b3ab..e994e031 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -2467,6 +2467,8 @@ export interface components { rootTagId?: string; rootTagName?: string; rootTagColor?: string; + /** Format: uuid */ + linkedEventId?: string; }; TimelineYearDTO: { /** Format: int32 */ -- 2.49.1 From 4b11d66ca533e9660850f6ad0c266535ea528679 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 10:31:03 +0200 Subject: [PATCH 03/25] feat(timeline): add the client-side letter regroup transform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure module powering the #827 Datum·Ereignis·Thema toggle: buildEventLookup (curated events that survived the #780 layer filter), hasLooseLetters (the control's enabled state), and bucketLetters (cluster loose letters by linkedEventId or primary root tag, with a "Weitere Briefe"/"Ohne Thema" fallback). Filter-then-group, no refetch. Refs #827 Co-Authored-By: Claude Opus 4.8 --- .../src/lib/timeline/timelineGrouping.spec.ts | 157 ++++++++++++++++++ frontend/src/lib/timeline/timelineGrouping.ts | 126 ++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 frontend/src/lib/timeline/timelineGrouping.spec.ts create mode 100644 frontend/src/lib/timeline/timelineGrouping.ts diff --git a/frontend/src/lib/timeline/timelineGrouping.spec.ts b/frontend/src/lib/timeline/timelineGrouping.spec.ts new file mode 100644 index 00000000..7a050ec4 --- /dev/null +++ b/frontend/src/lib/timeline/timelineGrouping.spec.ts @@ -0,0 +1,157 @@ +import { describe, it, expect } from 'vitest'; +import { buildEventLookup, bucketLetters, hasLooseLetters } from './timelineGrouping'; +import { makeEntry, makeYear, makeTimelineDTO } from './test-factories'; + +// Entry factories pinned to the shapes the grouping transform discriminates (#827). +const letter = (overrides = {}) => makeEntry({ kind: 'LETTER', ...overrides }); + +const curatedEvent = (id: string, title: string, overrides = {}) => + makeEntry({ + kind: 'EVENT', + type: 'PERSONAL', + derived: false, + documentId: undefined, + eventId: id, + title, + senderName: '', + receiverName: '', + ...overrides + }); + +describe('buildEventLookup (REQ-019)', () => { + it('collects curated events (eventId set) from year bands and the undated bucket', () => { + const dto = makeTimelineDTO({ + years: [makeYear(1915, [curatedEvent('e1', 'Briefe von der Front'), letter()])], + undated: [curatedEvent('e2', 'Unbekanntes Ereignis')] + }); + const lookup = buildEventLookup(dto); + expect(lookup.get('e1')).toBe('Briefe von der Front'); + expect(lookup.get('e2')).toBe('Unbekanntes Ereignis'); + expect(lookup.size).toBe(2); + }); + + it('ignores letters and derived life-events (no eventId)', () => { + const dto = makeTimelineDTO({ + years: [ + makeYear(1915, [ + letter({ linkedEventId: 'e1' }), + makeEntry({ kind: 'EVENT', type: 'PERSONAL', derived: true, eventId: undefined }) + ]) + ] + }); + expect(buildEventLookup(dto).size).toBe(0); + }); +}); + +describe('hasLooseLetters (REQ-018)', () => { + it('is true when a year band or the undated bucket holds a letter', () => { + expect(hasLooseLetters(makeTimelineDTO({ years: [makeYear(1915, [letter()])] }))).toBe(true); + expect(hasLooseLetters(makeTimelineDTO({ undated: [letter({ documentId: 'u1' })] }))).toBe( + true + ); + }); + + it('is false when only events remain', () => { + const dto = makeTimelineDTO({ years: [makeYear(1915, [curatedEvent('e1', 'Ereignis')])] }); + expect(hasLooseLetters(dto)).toBe(false); + }); +}); + +describe('bucketLetters — Ereignis mode (REQ-003/006/019)', () => { + const lookup = new Map([ + ['e1', 'Briefe von der Front'], + ['e2', 'Weihnachten 1915'] + ]); + + it('clusters letters under the curated event named by linkedEventId, with matching counts', () => { + const letters = [ + letter({ documentId: 'a', linkedEventId: 'e1' }), + letter({ documentId: 'b', linkedEventId: 'e1' }), + letter({ documentId: 'c', linkedEventId: 'e2' }) + ]; + const buckets = bucketLetters(letters, 'event', lookup); + const front = buckets.find((b) => b.title === 'Briefe von der Front'); + expect(front?.kind).toBe('event'); + expect(front?.letters).toHaveLength(2); + expect(buckets.find((b) => b.title === 'Weihnachten 1915')?.letters).toHaveLength(1); + }); + + it('drops a letter with no linkedEventId into the fallback bucket (REQ-006)', () => { + const letters = [letter({ documentId: 'a', linkedEventId: undefined })]; + const buckets = bucketLetters(letters, 'event', lookup); + expect(buckets).toHaveLength(1); + expect(buckets[0].kind).toBe('fallback'); + expect(buckets[0].letters).toHaveLength(1); + }); + + it('drops a letter whose linked event is absent from the lookup into fallback (REQ-019)', () => { + // e9 is not in the filtered view (its layer was toggled off) → no cluster. + const letters = [letter({ documentId: 'a', linkedEventId: 'e9' })]; + const buckets = bucketLetters(letters, 'event', lookup); + expect(buckets).toHaveLength(1); + expect(buckets[0].kind).toBe('fallback'); + }); + + it('keeps the fallback bucket last', () => { + const letters = [ + letter({ documentId: 'a', linkedEventId: undefined }), + letter({ documentId: 'b', linkedEventId: 'e1' }) + ]; + const buckets = bucketLetters(letters, 'event', lookup); + expect(buckets[buckets.length - 1].kind).toBe('fallback'); + }); +}); + +describe('bucketLetters — Thema mode (REQ-004/007/008)', () => { + const noEvents = new Map(); + + it('buckets letters under their primary root tag with name and colour', () => { + const letters = [ + letter({ documentId: 'a', rootTagId: 't1', rootTagName: 'Krieg', rootTagColor: 'sienna' }), + letter({ documentId: 'b', rootTagId: 't1', rootTagName: 'Krieg', rootTagColor: 'sienna' }), + letter({ + documentId: 'c', + rootTagId: 't2', + rootTagName: 'Weihnachten', + rootTagColor: 'amber' + }) + ]; + const buckets = bucketLetters(letters, 'thema', noEvents); + const krieg = buckets.find((b) => b.title === 'Krieg'); + expect(krieg?.kind).toBe('tag'); + expect(krieg?.color).toBe('sienna'); + expect(krieg?.letters).toHaveLength(2); + expect(buckets.find((b) => b.title === 'Weihnachten')?.color).toBe('amber'); + }); + + it('drops an untagged letter into the "Ohne Thema" fallback bucket (REQ-007)', () => { + const letters = [letter({ documentId: 'a', rootTagId: undefined })]; + const buckets = bucketLetters(letters, 'thema', noEvents); + expect(buckets).toHaveLength(1); + expect(buckets[0].kind).toBe('fallback'); + expect(buckets[0].color).toBeNull(); + }); + + it('places a letter in exactly one bucket (REQ-008)', () => { + const letters = [ + letter({ documentId: 'a', rootTagId: 't1', rootTagName: 'Krieg', rootTagColor: 'sienna' }) + ]; + const buckets = bucketLetters(letters, 'thema', noEvents); + const occurrences = buckets.flatMap((b) => b.letters).filter((l) => l.documentId === 'a'); + expect(occurrences).toHaveLength(1); + }); + + it('carries a null colour through for a colourless root tag', () => { + const letters = [ + letter({ + documentId: 'a', + rootTagId: 't3', + rootTagName: 'Allgemein', + rootTagColor: undefined + }) + ]; + const buckets = bucketLetters(letters, 'thema', noEvents); + expect(buckets[0].kind).toBe('tag'); + expect(buckets[0].color).toBeNull(); + }); +}); diff --git a/frontend/src/lib/timeline/timelineGrouping.ts b/frontend/src/lib/timeline/timelineGrouping.ts new file mode 100644 index 00000000..13f317ac --- /dev/null +++ b/frontend/src/lib/timeline/timelineGrouping.ts @@ -0,0 +1,126 @@ +import type { components } from '$lib/generated/api'; + +type TimelineDTO = components['schemas']['TimelineDTO']; +type TimelineEntryDTO = components['schemas']['TimelineEntryDTO']; + +/** + * The three ways a reader can bundle the loose letters on `/zeitstrahl` (#827). The + * axis-fixed layers (life-events, event pills, world-bands) are identical in every mode + * — only loose-letter bundling changes. Grouping runs over the *already layer-filtered* + * timeline (#780): filter-then-group. + */ +export type GroupingMode = 'date' | 'event' | 'thema'; + +/** The default mode — chronological, as #779 shipped. */ +export const DEFAULT_GROUPING: GroupingMode = 'date'; + +/** + * One bundle of loose letters under a single header, within a year (Ereignis/Thema modes). + * `kind` decides the header: a curated-event title, a tinted root-tag chip, or the localized + * fallback ("Weitere Briefe" / "Ohne Thema") the view supplies for `kind === 'fallback'`. + */ +export interface LetterBucket { + /** Stable `{#each}` key, unique within a year's bucket list. */ + key: string; + kind: 'event' | 'tag' | 'fallback'; + /** Header label for `event`/`tag` buckets; absent for `fallback` (view supplies a localized label). */ + title?: string; + /** Root-tag colour token for a `tag` bucket; `null` for `event`/`fallback` (neutral). */ + color: string | null; + letters: TimelineEntryDTO[]; +} + +/** + * Maps each curated event present in the (already-filtered) timeline to its title. These are the + * only events a letter may cluster under — a letter whose `linkedEventId` is absent here links to + * an event the layer filter removed, so it falls back to "Weitere Briefe" (filter-then-group, + * REQ-019). Curated events carry an `eventId`; derived life-events and letters do not. + */ +export function buildEventLookup(timeline: TimelineDTO): Map { + const lookup = new Map(); + const collect = (entries: TimelineEntryDTO[]) => { + for (const entry of entries) { + if (entry.kind === 'EVENT' && entry.eventId) lookup.set(entry.eventId, entry.title ?? ''); + } + }; + for (const band of timeline.years) collect(band.entries); + collect(timeline.undated); + return lookup; +} + +/** + * True when the timeline still holds at least one loose letter. Drives the grouping control's + * enabled state: with the Letters layer filtered off there is nothing to regroup (REQ-018). + */ +export function hasLooseLetters(timeline: TimelineDTO): boolean { + const holdsLetter = (entries: TimelineEntryDTO[]) => entries.some((e) => e.kind === 'LETTER'); + return timeline.years.some((band) => holdsLetter(band.entries)) || holdsLetter(timeline.undated); +} + +/** + * Buckets one year's loose letters for Ereignis/Thema mode. The caller passes only that year's + * `LETTER` entries; events stay on the axis untouched (REQ-001). Buckets keep first-seen order and + * the fallback bucket, if any, always sorts last. + * + * - `event`: cluster under `linkedEventId` when it is set AND survives in `eventLookup`; otherwise + * the fallback "Weitere Briefe" bucket (REQ-003/006/019). + * - `thema`: bucket under `rootTagId` (header = `rootTagName`, tint = `rootTagColor`); an untagged + * letter goes to the fallback "Ohne Thema" bucket (REQ-004/007). A letter carries exactly one + * `rootTagId`, so it lands in exactly one bucket (REQ-008). + */ +export function bucketLetters( + letters: TimelineEntryDTO[], + mode: Exclude, + eventLookup: Map +): LetterBucket[] { + const byKey = new Map(); + let fallback: LetterBucket | null = null; + + const fallbackBucket = (): LetterBucket => { + if (!fallback) fallback = { key: '__fallback__', kind: 'fallback', color: null, letters: [] }; + return fallback; + }; + + const namedBucket = (id: string, build: () => LetterBucket): LetterBucket => { + let bucket = byKey.get(id); + if (!bucket) { + bucket = build(); + byKey.set(id, bucket); + } + return bucket; + }; + + for (const letter of letters) { + if (mode === 'event') { + const id = letter.linkedEventId; + if (id && eventLookup.has(id)) { + namedBucket(id, () => ({ + key: `event:${id}`, + kind: 'event', + title: eventLookup.get(id), + color: null, + letters: [] + })).letters.push(letter); + } else { + fallbackBucket().letters.push(letter); + } + } else { + const id = letter.rootTagId; + if (id) { + namedBucket(id, () => ({ + key: `tag:${id}`, + kind: 'tag', + title: letter.rootTagName ?? '', + color: letter.rootTagColor ?? null, + letters: [] + })).letters.push(letter); + } else { + fallbackBucket().letters.push(letter); + } + } + } + + const buckets = [...byKey.values()]; + if (fallback) buckets.push(fallback); + return buckets; +} -- 2.49.1 From 99528e6bea390cb77fda0e4ce44e1e04fdc60830 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 10:34:29 +0200 Subject: [PATCH 04/25] feat(timeline): add the tinted Thema bucket-header chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fully-tinted root-tag chip for Thema-mode bucket headers (#827, REQ-015): fill and label both derive from the tag's --c-tag-* token via a color-mix wash so the label keeps ≥4.5:1 contrast in light and dark mode. A null or unknown token falls back to a neutral chip with no broken colour. Curator text is {...}-escaped (REQ-009). Distinct from the neutral per-letter TagChip. Refs #827 Co-Authored-By: Claude Opus 4.8 --- .../src/lib/timeline/BucketHeaderChip.svelte | 59 +++++++++++++++++++ .../timeline/BucketHeaderChip.svelte.spec.ts | 44 ++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 frontend/src/lib/timeline/BucketHeaderChip.svelte create mode 100644 frontend/src/lib/timeline/BucketHeaderChip.svelte.spec.ts diff --git a/frontend/src/lib/timeline/BucketHeaderChip.svelte b/frontend/src/lib/timeline/BucketHeaderChip.svelte new file mode 100644 index 00000000..9d12cab4 --- /dev/null +++ b/frontend/src/lib/timeline/BucketHeaderChip.svelte @@ -0,0 +1,59 @@ + + + + {m.timeline_tag_chip_label()}: + + {name} + diff --git a/frontend/src/lib/timeline/BucketHeaderChip.svelte.spec.ts b/frontend/src/lib/timeline/BucketHeaderChip.svelte.spec.ts new file mode 100644 index 00000000..61c926ad --- /dev/null +++ b/frontend/src/lib/timeline/BucketHeaderChip.svelte.spec.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import * as m from '$lib/paraglide/messages.js'; +import BucketHeaderChip from './BucketHeaderChip.svelte'; + +afterEach(() => cleanup()); + +describe('BucketHeaderChip (REQ-015/009)', () => { + it('renders the root-tag name', () => { + render(BucketHeaderChip, { name: 'Krieg', color: 'sienna' }); + expect(document.body.textContent).toContain('Krieg'); + }); + + it('tints the chip with var(--c-tag-{token}) for a known colour token (REQ-015)', () => { + render(BucketHeaderChip, { name: 'Krieg', color: 'sienna' }); + const chip = document.querySelector('[data-testid="bucket-header-chip"]') as HTMLElement; + expect(chip.getAttribute('style')).toContain('var(--c-tag-sienna)'); + }); + + it('renders a neutral chip with no --c-tag- binding when colour is null (REQ-015)', () => { + render(BucketHeaderChip, { name: 'Ohne Thema', color: null }); + expect(document.body.textContent).toContain('Ohne Thema'); + expect(document.body.innerHTML).not.toContain('var(--c-tag-'); + }); + + it('falls back to neutral for an unknown colour token, never a broken var (REQ-015)', () => { + // "krieg" is a §2 demo class name, not a real --c-tag-* token. + render(BucketHeaderChip, { name: 'Krieg', color: 'krieg' }); + expect(document.body.innerHTML).not.toContain('var(--c-tag-'); + }); + + it('prefixes the name with an sr-only theme label so colour is never the only cue', () => { + render(BucketHeaderChip, { name: 'Krieg', color: 'sienna' }); + const srOnly = document.querySelector('.sr-only'); + expect(srOnly?.textContent).toContain(m.timeline_tag_chip_label()); + }); + + it('renders an HTML-bearing name as inert text, never markup (REQ-009)', () => { + const evil = ''; + render(BucketHeaderChip, { name: evil, color: null }); + expect(document.body.textContent).toContain(evil); + expect(document.querySelector('img')).toBeNull(); + }); +}); -- 2.49.1 From 0ae4e9a3118bb1d2d94aa5f943f4b3a5d22e27f1 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 10:37:54 +0200 Subject: [PATCH 05/25] feat(timeline): give LetterCard an event variant and chip suppression Add a `variant="event"` that marks the card `.lcard.ev` for Ereignis-mode event clusters (#827, REQ-014) and a `suppressTagChip` that hides the per-letter TagChip inside its own Thema bucket where the header already conveys the topic (REQ-017). Datum/Ereignis keep the #838 per-letter chip behaviour. Refs #827 Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/timeline/LetterCard.svelte | 20 ++++++++++++--- .../lib/timeline/LetterCard.svelte.spec.ts | 25 +++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/timeline/LetterCard.svelte b/frontend/src/lib/timeline/LetterCard.svelte index 593156fc..84a76bfb 100644 --- a/frontend/src/lib/timeline/LetterCard.svelte +++ b/frontend/src/lib/timeline/LetterCard.svelte @@ -12,9 +12,19 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO']; * 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 the raw-HTML directive. + * + * In Ereignis mode the card sits inside an event cluster and renders as the + * `.lcard.ev` variant (#827, REQ-014). In Thema mode the per-letter tag chip is + * suppressed inside its own root-tag bucket, where the bucket header already + * carries the topic (`suppressTagChip`, REQ-017). */ -let { entry }: { entry: TimelineEntryDTO } = $props(); +let { + entry, + variant = 'plain', + suppressTagChip = false +}: { entry: TimelineEntryDTO; variant?: 'plain' | 'event'; suppressTagChip?: boolean } = $props(); +const isEventVariant = $derived(variant === 'event'); const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd)); const sender = $derived(entry.senderName === '' ? m.timeline_unknown_person() : entry.senderName); const receiver = $derived( @@ -28,7 +38,8 @@ const receiver = $derived( {#if entry.title} + (#835 §3); absent when the letter has no tag (REQ-005), and suppressed in + Thema mode inside its own root-tag bucket where the header conveys it (REQ-017). --> {/if} diff --git a/frontend/src/lib/timeline/LetterCard.svelte.spec.ts b/frontend/src/lib/timeline/LetterCard.svelte.spec.ts index b60c0f5f..df36bffc 100644 --- a/frontend/src/lib/timeline/LetterCard.svelte.spec.ts +++ b/frontend/src/lib/timeline/LetterCard.svelte.spec.ts @@ -127,3 +127,28 @@ describe('LetterCard', () => { expect(chip?.textContent).toContain('Familie'); }); }); + +describe('LetterCard — grouping variants (#827, REQ-014/017)', () => { + it('carries the .lcard.ev class in the event variant (REQ-014)', () => { + render(LetterCard, { entry: makeEntry(), variant: 'event' }); + expect(document.querySelector('a.lcard.ev')).not.toBeNull(); + }); + + it('is a plain card with no .ev marker by default (REQ-014)', () => { + render(LetterCard, { entry: makeEntry() }); + expect(document.querySelector('a.ev')).toBeNull(); + }); + + it('suppresses the per-letter tag chip when asked, even with a root tag (REQ-017)', () => { + render(LetterCard, { + entry: makeEntry({ rootTagName: 'Krieg', rootTagColor: 'sienna' }), + suppressTagChip: true + }); + expect(document.querySelector('[data-testid="tag-chip"]')).toBeNull(); + }); + + it('still shows the per-letter tag chip when not suppressed — Datum/Ereignis (REQ-017)', () => { + render(LetterCard, { entry: makeEntry({ rootTagName: 'Krieg', rootTagColor: 'sienna' }) }); + expect(document.querySelector('[data-testid="tag-chip"]')).not.toBeNull(); + }); +}); -- 2.49.1 From fd67a216100680eae5a2e9cbfd0b8dcc726f22fb Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 10:40:12 +0200 Subject: [PATCH 06/25] feat(i18n): add grouping + bucket message keys for the Zeitstrahl toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New de/en/es keys for #827: the Datum·Ereignis·Thema segment labels and their ≤320px abbreviations, the dynamic meta-line grouping labels (timeline_grouping_event/_thema), the "Weitere Briefe"/"Ohne Thema" bucket labels, the radiogroup aria-label, the letters-hidden disabled reason, and the multi-tag hint. Reuses the existing timeline_grouping_date / timeline_tag_chip_label. Refs #827 Co-Authored-By: Claude Opus 4.8 --- frontend/messages/de.json | 13 +++++++++++++ frontend/messages/en.json | 13 +++++++++++++ frontend/messages/es.json | 13 +++++++++++++ 3 files changed, 39 insertions(+) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 6be0122c..bc27db1a 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -1050,6 +1050,19 @@ "timeline_derived_death": "Tod", "timeline_derived_marriage": "Heirat", "timeline_grouping_date": "Gruppierung: Datum", + "timeline_grouping_event": "Gruppierung: Ereignis", + "timeline_grouping_thema": "Gruppierung: Thema", + "timeline_grouping_aria_label": "Gruppierung", + "timeline_grouping_segment_date": "Datum", + "timeline_grouping_segment_event": "Ereignis", + "timeline_grouping_segment_thema": "Thema", + "timeline_grouping_segment_date_short": "Dat.", + "timeline_grouping_segment_event_short": "Ereig.", + "timeline_grouping_segment_thema_short": "Thema", + "timeline_grouping_disabled_reason": "Briefe sind ausgeblendet – es gibt nichts zu gruppieren.", + "timeline_grouping_multitag_hint": "Brief mit mehreren Tags erscheint unter seinem primären Tag.", + "timeline_bucket_other_letters": "Weitere Briefe", + "timeline_bucket_no_topic": "Ohne Thema", "timeline_provenance_derived": "abgeleitet", "timeline_provenance_curated": "kuratiert", "timeline_letter_glyph_label": "Brief", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index b07fe58e..dd4a9271 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1050,6 +1050,19 @@ "timeline_derived_death": "Death", "timeline_derived_marriage": "Marriage", "timeline_grouping_date": "Grouping: Date", + "timeline_grouping_event": "Grouping: Event", + "timeline_grouping_thema": "Grouping: Topic", + "timeline_grouping_aria_label": "Grouping", + "timeline_grouping_segment_date": "Date", + "timeline_grouping_segment_event": "Event", + "timeline_grouping_segment_thema": "Topic", + "timeline_grouping_segment_date_short": "Date", + "timeline_grouping_segment_event_short": "Event", + "timeline_grouping_segment_thema_short": "Topic", + "timeline_grouping_disabled_reason": "Letters are hidden — there is nothing to group.", + "timeline_grouping_multitag_hint": "A letter with several tags appears under its primary tag.", + "timeline_bucket_other_letters": "More letters", + "timeline_bucket_no_topic": "No topic", "timeline_provenance_derived": "derived", "timeline_provenance_curated": "curated", "timeline_letter_glyph_label": "Letter", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 2048c1f6..a1712d41 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1050,6 +1050,19 @@ "timeline_derived_death": "Fallecimiento", "timeline_derived_marriage": "Matrimonio", "timeline_grouping_date": "Agrupación: Fecha", + "timeline_grouping_event": "Agrupación: Evento", + "timeline_grouping_thema": "Agrupación: Tema", + "timeline_grouping_aria_label": "Agrupación", + "timeline_grouping_segment_date": "Fecha", + "timeline_grouping_segment_event": "Evento", + "timeline_grouping_segment_thema": "Tema", + "timeline_grouping_segment_date_short": "Fecha", + "timeline_grouping_segment_event_short": "Evento", + "timeline_grouping_segment_thema_short": "Tema", + "timeline_grouping_disabled_reason": "Las cartas están ocultas: no hay nada que agrupar.", + "timeline_grouping_multitag_hint": "Una carta con varias etiquetas aparece bajo su etiqueta principal.", + "timeline_bucket_other_letters": "Más cartas", + "timeline_bucket_no_topic": "Sin tema", "timeline_provenance_derived": "derivado", "timeline_provenance_curated": "curado", "timeline_letter_glyph_label": "Carta", -- 2.49.1 From f3c2465465d360cb19e3a47e48d7afdec4b3a1c9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 10:42:45 +0200 Subject: [PATCH 07/25] feat(timeline): add the LetterBucket cluster component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders one loose-letter cluster for Ereignis/Thema mode (#827): an "✉ · " header over .lcard.ev cards in Ereignis, a tinted BucketHeaderChip over chip-suppressed cards in Thema, and a localized "Weitere Briefe"/"Ohne Thema" header with plain cards for the fallback bucket. Refs #827 Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/timeline/LetterBucket.svelte | 49 ++++++++++++ .../lib/timeline/LetterBucket.svelte.spec.ts | 74 +++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 frontend/src/lib/timeline/LetterBucket.svelte create mode 100644 frontend/src/lib/timeline/LetterBucket.svelte.spec.ts diff --git a/frontend/src/lib/timeline/LetterBucket.svelte b/frontend/src/lib/timeline/LetterBucket.svelte new file mode 100644 index 00000000..3b0f986c --- /dev/null +++ b/frontend/src/lib/timeline/LetterBucket.svelte @@ -0,0 +1,49 @@ + + +

+
+ {#if mode === 'thema' && bucket.kind === 'tag'} + + {:else if mode === 'event' && bucket.kind === 'event'} + + + {bucket.title} + + {:else} + {fallbackLabel} + {/if} + · {count} +
+
    + {#each bucket.letters as letter (entryKey(letter))} +
  • + {#if mode === 'event'} + + {:else} + + {/if} +
  • + {/each} +
+
diff --git a/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts b/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts new file mode 100644 index 00000000..c024c9f5 --- /dev/null +++ b/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import * as m from '$lib/paraglide/messages.js'; +import LetterBucket from './LetterBucket.svelte'; +import { makeEntry } from './test-factories'; +import type { LetterBucket as Bucket } from './timelineGrouping'; + +afterEach(() => cleanup()); + +const eventBucket: Bucket = { + key: 'event:e1', + kind: 'event', + title: 'Briefe von der Front', + color: null, + letters: [makeEntry({ documentId: 'a' }), makeEntry({ documentId: 'b' })] +}; + +const tagBucket: Bucket = { + key: 'tag:t1', + kind: 'tag', + title: 'Krieg', + color: 'sienna', + letters: [makeEntry({ documentId: 'c', rootTagName: 'Krieg', rootTagColor: 'sienna' })] +}; + +describe('LetterBucket — Ereignis mode (REQ-003/006/014)', () => { + it('shows the event title and the cluster count', () => { + render(LetterBucket, { bucket: eventBucket, mode: 'event' }); + expect(document.body.textContent).toContain('Briefe von der Front'); + expect(document.querySelector('[data-testid="bucket-count"]')?.textContent).toContain('2'); + }); + + it('renders its letters as .lcard.ev event cards (REQ-014)', () => { + render(LetterBucket, { bucket: eventBucket, mode: 'event' }); + expect(document.querySelectorAll('a.lcard.ev')).toHaveLength(2); + }); + + it('uses the localized "Weitere Briefe" label and plain cards for the fallback bucket (REQ-006)', () => { + const fb: Bucket = { + key: '__fallback__', + kind: 'fallback', + color: null, + letters: [makeEntry({ documentId: 'x' })] + }; + render(LetterBucket, { bucket: fb, mode: 'event' }); + expect(document.body.textContent).toContain(m.timeline_bucket_other_letters()); + // fallback letters are not clustered under a curated event → plain card, never .lcard.ev + expect(document.querySelector('a.ev')).toBeNull(); + }); +}); + +describe('LetterBucket — Thema mode (REQ-004/007/015/017)', () => { + it('renders a tinted bucket-header chip carrying the root-tag name (REQ-015)', () => { + render(LetterBucket, { bucket: tagBucket, mode: 'thema' }); + const chip = document.querySelector('[data-testid="bucket-header-chip"]'); + expect(chip?.textContent).toContain('Krieg'); + }); + + it('suppresses the per-letter tag chip inside its own root-tag bucket (REQ-017)', () => { + render(LetterBucket, { bucket: tagBucket, mode: 'thema' }); + expect(document.querySelector('[data-testid="tag-chip"]')).toBeNull(); + }); + + it('uses the localized "Ohne Thema" label for the untagged fallback bucket (REQ-007)', () => { + const fb: Bucket = { + key: '__fallback__', + kind: 'fallback', + color: null, + letters: [makeEntry({ documentId: 'y', rootTagName: undefined })] + }; + render(LetterBucket, { bucket: fb, mode: 'thema' }); + expect(document.body.textContent).toContain(m.timeline_bucket_no_topic()); + }); +}); -- 2.49.1 From bc22b2d4c95ff7a692555109c6a6d3893b5ce7b5 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 10:48:36 +0200 Subject: [PATCH 08/25] =?UTF-8?q?feat(timeline):=20add=20the=20Datum=C2=B7?= =?UTF-8?q?Ereignis=C2=B7Thema=20grouping=20control?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An ARIA radiogroup with roving tabindex (#827, REQ-010/011/018): three arrow-key-navigable ≥44px segments with text labels, full-word aria-labels and ≤360px abbreviations, semantic-token colours that hold contrast in dark mode, defaulting to Datum. When disabled it stays in place, retains its selection, and announces a screen-reader reason — deliberately distinct from #780's aria-pressed layer toggles. Refs #827 Co-Authored-By: Claude Opus 4.8 --- .../src/lib/timeline/GroupingControl.svelte | 114 ++++++++++++++++++ .../timeline/GroupingControl.svelte.spec.ts | 106 ++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 frontend/src/lib/timeline/GroupingControl.svelte create mode 100644 frontend/src/lib/timeline/GroupingControl.svelte.spec.ts diff --git a/frontend/src/lib/timeline/GroupingControl.svelte b/frontend/src/lib/timeline/GroupingControl.svelte new file mode 100644 index 00000000..681c289d --- /dev/null +++ b/frontend/src/lib/timeline/GroupingControl.svelte @@ -0,0 +1,114 @@ + + +
+ {#each segments as segment (segment.value)} + + {/each} +
+{#if disabled} + {m.timeline_grouping_disabled_reason()} +{/if} + + diff --git a/frontend/src/lib/timeline/GroupingControl.svelte.spec.ts b/frontend/src/lib/timeline/GroupingControl.svelte.spec.ts new file mode 100644 index 00000000..6516881d --- /dev/null +++ b/frontend/src/lib/timeline/GroupingControl.svelte.spec.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { tick } from 'svelte'; +import * as m from '$lib/paraglide/messages.js'; +import GroupingControl from './GroupingControl.svelte'; + +afterEach(() => cleanup()); + +const radios = () => Array.from(document.querySelectorAll('[role="radio"]')) as HTMLElement[]; +const group = () => document.querySelector('[role="radiogroup"]') as HTMLElement; +const checkedValue = () => + radios() + .find((r) => r.getAttribute('aria-checked') === 'true') + ?.getAttribute('data-value'); + +describe('GroupingControl (REQ-010)', () => { + it('renders three radios inside a radiogroup, each with aria-checked (a)', () => { + render(GroupingControl, {}); + expect(group()).not.toBeNull(); + const r = radios(); + expect(r).toHaveLength(3); + r.forEach((radio) => expect(radio.hasAttribute('aria-checked')).toBe(true)); + }); + + it('defaults to Datum (f)', () => { + render(GroupingControl, {}); + expect(radios().filter((r) => r.getAttribute('aria-checked') === 'true')).toHaveLength(1); + expect(checkedValue()).toBe('date'); + }); + + it('exposes a text label on every segment, not colour alone (d)', () => { + render(GroupingControl, {}); + radios().forEach((r) => expect((r.textContent ?? '').trim().length).toBeGreaterThan(0)); + }); + + it('gives the radiogroup an accessible name (e)', () => { + render(GroupingControl, {}); + expect(group().getAttribute('aria-label')).toBe(m.timeline_grouping_aria_label()); + }); + + it('each segment has a tap target of at least 44×44px (c)', () => { + render(GroupingControl, {}); + radios().forEach((r) => { + const rect = r.getBoundingClientRect(); + expect(rect.width).toBeGreaterThanOrEqual(44); + expect(rect.height).toBeGreaterThanOrEqual(44); + }); + }); + + it('exposes each segment full word as an aria-label (REQ-011)', () => { + render(GroupingControl, {}); + const labels = radios().map((r) => r.getAttribute('aria-label')); + expect(labels).toEqual([ + m.timeline_grouping_segment_date(), + m.timeline_grouping_segment_event(), + m.timeline_grouping_segment_thema() + ]); + }); + + it('moves the selection forward with the right arrow key (b)', async () => { + render(GroupingControl, { mode: 'date' }); + group().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + await tick(); + expect(checkedValue()).toBe('event'); + }); + + it('wraps to the last segment with the left arrow from Datum (b)', async () => { + render(GroupingControl, { mode: 'date' }); + group().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); + await tick(); + expect(checkedValue()).toBe('thema'); + }); + + it('selects a segment on click', async () => { + render(GroupingControl, { mode: 'date' }); + const thema = radios().find((r) => r.getAttribute('data-value') === 'thema')!; + thema.click(); + await tick(); + expect(thema.getAttribute('aria-checked')).toBe('true'); + }); +}); + +describe('GroupingControl — disabled (REQ-018)', () => { + it('marks the radiogroup aria-disabled and keeps all radios in the DOM', () => { + render(GroupingControl, { mode: 'event', disabled: true }); + expect(group().getAttribute('aria-disabled')).toBe('true'); + expect(radios()).toHaveLength(3); + }); + + it('announces a screen-reader reason that letters are hidden', () => { + render(GroupingControl, { disabled: true }); + const reason = document.querySelector('[data-testid="grouping-disabled-reason"]'); + expect(reason?.textContent).toContain(m.timeline_grouping_disabled_reason()); + }); + + it('retains the active mode while disabled (no reset to Datum)', () => { + render(GroupingControl, { mode: 'thema', disabled: true }); + expect(checkedValue()).toBe('thema'); + }); + + it('ignores arrow keys while disabled', () => { + render(GroupingControl, { mode: 'event', disabled: true }); + group().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + expect(checkedValue()).toBe('event'); + }); +}); -- 2.49.1 From 8be4b40e54c03204a51021af4692881c6b06cc6d Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 10:52:48 +0200 Subject: [PATCH 09/25] feat(timeline): render letter buckets in TimelineView/YearBand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread groupingMode through TimelineView → YearBand. TimelineView resolves the event lookup once over the filtered view (so Ereignis clusters never reference a filtered-out event). In non-Datum modes YearBand keeps its event pills/world-bands identical (REQ-001) and replaces the loose letters with per-year LetterBuckets (REQ-002/003/004); Datum keeps the original card/strip path. The undated bucket is unchanged in every mode. Refs #827 Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/timeline/TimelineView.svelte | 28 ++++++++- frontend/src/lib/timeline/YearBand.svelte | 49 +++++++++++++++- .../src/lib/timeline/YearBand.svelte.spec.ts | 57 +++++++++++++++++++ 3 files changed, 128 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/timeline/TimelineView.svelte b/frontend/src/lib/timeline/TimelineView.svelte index c3008e90..aac64896 100644 --- a/frontend/src/lib/timeline/TimelineView.svelte +++ b/frontend/src/lib/timeline/TimelineView.svelte @@ -6,6 +6,7 @@ import LetterCard from './LetterCard.svelte'; import EventPill from './EventPill.svelte'; import WorldBand from './WorldBand.svelte'; import { entryKey } from './entryKey'; +import { buildEventLookup, type GroupingMode } from './timelineGrouping'; import type { components } from '$lib/generated/api'; type TimelineDTO = components['schemas']['TimelineDTO']; @@ -18,12 +19,28 @@ type TimelineYearDTO = components['schemas']['TimelineYearDTO']; * empty timeline shows a calm message (REQ-017). `personId` is a declared seam * for the per-person rail (issue #10) and is undefined here; it is not passed to * leaf cards (REQ-025). Owns no
— the layout does. + * + * `groupingMode` (#827) flows down to each YearBand to re-bundle its loose letters; + * the event lookup — the curated events present in this (already layer-filtered) + * view — is resolved once here so Ereignis clusters never reference a filtered-out + * event (filter-then-group, REQ-019). The undated bucket renders unchanged in every + * mode (its letters have no year, so the per-year bucketing does not apply). */ let { timeline, personId = undefined, - canWrite = false -}: { timeline: TimelineDTO; personId?: string; canWrite?: boolean } = $props(); + canWrite = false, + groupingMode = 'date' +}: { + timeline: TimelineDTO; + personId?: string; + canWrite?: boolean; + groupingMode?: GroupingMode; +} = $props(); + +const eventLookup = $derived( + groupingMode === 'date' ? new Map() : buildEventLookup(timeline) +); type Row = { t: 'band'; year: TimelineYearDTO } | { t: 'gap'; from: number; to: number }; @@ -54,7 +71,12 @@ const isEmpty = $derived(timeline.years.length === 0 && timeline.undated.length {#each rows as row (row.t === 'band' ? `band-${row.year.year}` : `gap-${row.from}`)}
  • {#if row.t === 'band'} - + {:else} {/if} diff --git a/frontend/src/lib/timeline/YearBand.svelte b/frontend/src/lib/timeline/YearBand.svelte index fafa0b4c..d60012bc 100644 --- a/frontend/src/lib/timeline/YearBand.svelte +++ b/frontend/src/lib/timeline/YearBand.svelte @@ -3,8 +3,14 @@ import EventPill from './EventPill.svelte'; import WorldBand from './WorldBand.svelte'; import LetterCard from './LetterCard.svelte'; import YearLetterStrip from './YearLetterStrip.svelte'; +import LetterBucket from './LetterBucket.svelte'; import { isDense } from './timelineDensity'; import { entryKey } from './entryKey'; +import { + bucketLetters, + type GroupingMode, + type LetterBucket as LetterBucketModel +} from './timelineGrouping'; import type { components } from '$lib/generated/api'; type TimelineYearDTO = components['schemas']['TimelineYearDTO']; @@ -15,19 +21,48 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO']; * render in DTO order as pills/bands; letters render as individual cards while * the band holds ≤ 12 (REQ-011) or collapse to a single density strip above that * (REQ-012). Entries are never re-sorted — DTO order is preserved (REQ-003). + * + * In Ereignis/Thema mode (#827) the event pills/world-bands render identically + * (REQ-001); only the loose letters re-bundle into per-year buckets below them + * (REQ-002/003/004). Datum mode is the original individual-card / density-strip + * path, untouched. */ -let { year, canWrite = false }: { year: TimelineYearDTO; canWrite?: boolean } = $props(); +let { + year, + canWrite = false, + groupingMode = 'date', + eventLookup = new Map() +}: { + year: TimelineYearDTO; + canWrite?: boolean; + groupingMode?: GroupingMode; + eventLookup?: Map; +} = $props(); type Row = | { t: 'event'; entry: TimelineEntryDTO } | { t: 'letter'; entry: TimelineEntryDTO; side: 'left' | 'right' } - | { t: 'strip' }; + | { t: 'strip' } + | { t: 'bucket'; bucket: LetterBucketModel }; const letters = $derived(year.entries.filter((e) => e.kind === 'LETTER')); const dense = $derived(isDense(letters.length)); +const grouped = $derived(groupingMode !== 'date'); +const bucketMode = $derived(groupingMode === 'thema' ? 'thema' : 'event'); const rows = $derived.by(() => { const out: Row[] = []; + if (grouped) { + // Events stay on the axis, identical to Datum mode (REQ-001); only the loose + // letters re-bundle into per-year buckets below them (REQ-003/004). + for (const entry of year.entries) { + if (entry.kind === 'EVENT') out.push({ t: 'event', entry }); + } + for (const bucket of bucketLetters(letters, bucketMode, eventLookup)) { + out.push({ t: 'bucket', bucket }); + } + return out; + } let stripInserted = false; let letterIndex = 0; for (const entry of year.entries) { @@ -43,6 +78,12 @@ const rows = $derived.by(() => { } return out; }); + +function rowKey(row: Row): string { + if (row.t === 'strip') return `strip-${year.year}`; + if (row.t === 'bucket') return row.bucket.key; + return entryKey(row.entry); +}
    @@ -56,7 +97,7 @@ const rows = $derived.by(() => {
    - {#each rows as row (row.t === 'strip' ? `strip-${year.year}` : entryKey(row.entry))} + {#each rows as row (rowKey(row))} {#if row.t === 'event'} {#if row.entry.type === 'HISTORICAL'} @@ -68,6 +109,8 @@ const rows = $derived.by(() => {
    + {:else if row.t === 'bucket'} + {:else} {/if} diff --git a/frontend/src/lib/timeline/YearBand.svelte.spec.ts b/frontend/src/lib/timeline/YearBand.svelte.spec.ts index 45845080..2481c6a2 100644 --- a/frontend/src/lib/timeline/YearBand.svelte.spec.ts +++ b/frontend/src/lib/timeline/YearBand.svelte.spec.ts @@ -165,3 +165,60 @@ describe('YearBand', () => { } }); }); + +describe('YearBand — grouping modes (#827)', () => { + it('keeps individual letter cards and no buckets in Datum mode (default)', () => { + render(YearBand, { year: makeYear(1915, manyLetters(1915, 3)) }); + expect(document.querySelector('[data-testid="letter-bucket"]')).toBeNull(); + expect(document.querySelectorAll('a')).toHaveLength(3); + }); + + it('clusters loose letters under their linked event in Ereignis mode (REQ-002/003)', () => { + const a = makeEntry({ documentId: 'a', linkedEventId: 'e1', eventDate: '1915-03-01' }); + const b = makeEntry({ documentId: 'b', linkedEventId: 'e1', eventDate: '1915-04-01' }); + render(YearBand, { + year: makeYear(1915, [a, b]), + groupingMode: 'event', + eventLookup: new Map([['e1', 'Briefe von der Front']]) + }); + expect(document.querySelectorAll('[data-testid="letter-bucket"]')).toHaveLength(1); + expect(document.body.textContent).toContain('Briefe von der Front'); + // no alternating individual letter rows in grouped mode + expect(document.querySelector('.letter-row')).toBeNull(); + }); + + it('still renders the event world-band in Ereignis mode (REQ-001)', () => { + const band = makeEntry({ + kind: 'EVENT', + type: 'HISTORICAL', + precision: 'RANGE', + eventDate: '1914-01-01', + eventDateEnd: '1918-12-31', + title: 'Erster Weltkrieg', + senderName: '', + receiverName: '', + documentId: undefined + }); + const letter = makeEntry({ documentId: 'a', linkedEventId: 'e1', eventDate: '1914-05-01' }); + render(YearBand, { + year: makeYear(1914, [band, letter]), + groupingMode: 'event', + eventLookup: new Map([['e1', 'Front']]) + }); + expect(document.querySelector('[data-testid="world-range"]')).not.toBeNull(); + expect(document.querySelector('[data-testid="letter-bucket"]')).not.toBeNull(); + }); + + it('buckets loose letters under their root tag in Thema mode (REQ-004)', () => { + const a = makeEntry({ + documentId: 'a', + rootTagId: 't1', + rootTagName: 'Krieg', + rootTagColor: 'sienna', + eventDate: '1915-03-01' + }); + render(YearBand, { year: makeYear(1915, [a]), groupingMode: 'thema', eventLookup: new Map() }); + const chip = document.querySelector('[data-testid="bucket-header-chip"]'); + expect(chip?.textContent).toContain('Krieg'); + }); +}); -- 2.49.1 From 5936f3a9ae8f4fdb82d60c9f70e6134635c9ebad Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 10:56:55 +0200 Subject: [PATCH 10/25] feat(timeline): wire the grouping toggle into the Zeitstrahl page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the grouping $state beside the #780 layer-filter state, render the GroupingControl stacked above the filter trigger (disabled, but kept in place, when no loose letters remain), make the meta-line grouping label track the active mode, and thread groupingMode into TimelineView — filter-then-group, no refetch. Refs #827 Co-Authored-By: Claude Opus 4.8 --- frontend/src/routes/zeitstrahl/+page.svelte | 33 ++++++++++- .../src/routes/zeitstrahl/page.svelte.spec.ts | 58 +++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/frontend/src/routes/zeitstrahl/+page.svelte b/frontend/src/routes/zeitstrahl/+page.svelte index 759b3340..0c353d10 100644 --- a/frontend/src/routes/zeitstrahl/+page.svelte +++ b/frontend/src/routes/zeitstrahl/+page.svelte @@ -2,8 +2,10 @@ import * as m from '$lib/paraglide/messages.js'; import TimelineView from '$lib/timeline/TimelineView.svelte'; import TimelineFilters from '$lib/timeline/TimelineFilters.svelte'; +import GroupingControl from '$lib/timeline/GroupingControl.svelte'; import { timelineMeta } from '$lib/timeline/timelineMeta'; import { filterTimeline } from '$lib/timeline/timelineFilter'; +import { hasLooseLetters, type GroupingMode } from '$lib/timeline/timelineGrouping'; import type { PageData } from './$types'; let { data }: { data: PageData } = $props(); @@ -17,12 +19,20 @@ let personalOn = $state(true); let historicalOn = $state(true); let lettersOn = $state(true); +// Grouping state (#827) lives here beside the layer-filter state; the regroup is a +// pure client-side transform over the already-filtered view — filter-then-group. +let groupingMode = $state('date'); + const filteredTimeline = $derived( filterTimeline(data.timeline, { personalOn, historicalOn, lettersOn }) ); const filteredEmpty = $derived( filteredTimeline.years.length === 0 && filteredTimeline.undated.length === 0 ); +// The grouping control is only meaningful while loose letters remain in the filtered +// view; with the Letters layer off there is nothing to regroup, so it disables but +// keeps its selected mode (REQ-018). +const hasLetters = $derived(hasLooseLetters(filteredTimeline)); // Meta-line figures track the *filtered* view, so the header counts always // match what is actually on screen once layers are toggled off (#780 — this @@ -60,7 +70,13 @@ const metaLine = $derived.by(() => { : m.timeline_events_count({ count: meta.eventCount }) ); } - segments.push(m.timeline_grouping_date()); + segments.push( + groupingMode === 'event' + ? m.timeline_grouping_event() + : groupingMode === 'thema' + ? m.timeline_grouping_thema() + : m.timeline_grouping_date() + ); return segments.join(' · '); }); @@ -89,7 +105,14 @@ const metaLine = $derived.by(() => { {/if} {#if hasContent} -

    {metaLine}

    +

    {metaLine}

    + +
    + +
    { {:else} - + {/if} diff --git a/frontend/src/routes/zeitstrahl/page.svelte.spec.ts b/frontend/src/routes/zeitstrahl/page.svelte.spec.ts index 3f8a8d7b..8c79f89e 100644 --- a/frontend/src/routes/zeitstrahl/page.svelte.spec.ts +++ b/frontend/src/routes/zeitstrahl/page.svelte.spec.ts @@ -265,3 +265,61 @@ describe('/zeitstrahl curator affordances (#842)', () => { expect(document.querySelector('[data-testid="event-edit"]')).toBeNull(); }); }); + +describe('/zeitstrahl grouping toggle (#827)', () => { + const historical = () => + makeEntry({ + kind: 'EVENT', + type: 'HISTORICAL', + derived: false, + eventId: 'h1', + documentId: undefined, + title: 'Erster Weltkrieg', + senderName: '', + receiverName: '' + }); + const mixed = () => + makeTimelineDTO({ + years: [ + makeYear(1915, [ + makeEntry({ documentId: 'd1', title: 'Brief Eins', linkedEventId: 'h1' }), + historical() + ]) + ] + }); + const radio = (value: string) => document.querySelector(`[data-value="${value}"]`) as HTMLElement; + + it('updates the meta-line grouping label when a mode is chosen (REQ-016)', async () => { + render(Page, { data: pageData(mixed()) }); + const meta = page.getByTestId('timeline-meta'); + await expect.element(meta).toHaveTextContent(m.timeline_grouping_date()); + radio('event').click(); + await expect.element(meta).toHaveTextContent(m.timeline_grouping_event()); + radio('thema').click(); + await expect.element(meta).toHaveTextContent(m.timeline_grouping_thema()); + }); + + it('regroups loose letters under their event client-side, no buckets in Datum (REQ-002/003)', async () => { + render(Page, { data: pageData(mixed()) }); + expect(document.querySelector('[data-testid="letter-bucket"]')).toBeNull(); + radio('event').click(); + await expect.element(page.getByTestId('letter-bucket')).toBeVisible(); + }); + + it('disables the grouping control when the Letters layer is off, keeping the mode (REQ-018)', async () => { + render(Page, { data: pageData(mixed()) }); + radio('thema').click(); + const control = page.getByTestId('grouping-control'); + await expect.element(control).toHaveAttribute('aria-disabled', 'false'); + // turn the Letters layer off → nothing to regroup + await page.getByTestId('timeline-filter-trigger').click(); + await page.getByTestId('timeline-filter-letters').click(); + await expect.element(control).toHaveAttribute('aria-disabled', 'true'); + // the chosen mode is retained for when letters return + expect(radio('thema').getAttribute('aria-checked')).toBe('true'); + // re-enabling restores the enabled control with the same mode (no reset to Datum) + await page.getByTestId('timeline-filter-letters').click(); + await expect.element(control).toHaveAttribute('aria-disabled', 'false'); + expect(radio('thema').getAttribute('aria-checked')).toBe('true'); + }); +}); -- 2.49.1 From 6c85f4779433d0293d9a0d95b4b592c729a51ed0 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 11:00:16 +0200 Subject: [PATCH 11/25] test(timeline): gate the event layer identity and the {@html} ban MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the REQ-001 structural-identity check (the event pills/world-bands render identically across all three grouping modes — the no-VRT-harness equivalent of the pixel-diff) and the REQ-009 grep gate (no lib/timeline component reaches for the raw-HTML directive). Reword the BucketHeaderChip doc to describe the directive by name so the gate stays literal. Refs #827 Co-Authored-By: Claude Opus 4.8 --- .../src/lib/timeline/BucketHeaderChip.svelte | 3 +- ...ouping-event-layer-identity.svelte.spec.ts | 93 +++++++++++++++++++ .../lib/timeline/timeline-no-raw-html.spec.ts | 23 +++++ 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/timeline/grouping-event-layer-identity.svelte.spec.ts create mode 100644 frontend/src/lib/timeline/timeline-no-raw-html.spec.ts diff --git a/frontend/src/lib/timeline/BucketHeaderChip.svelte b/frontend/src/lib/timeline/BucketHeaderChip.svelte index 9d12cab4..d3fa1561 100644 --- a/frontend/src/lib/timeline/BucketHeaderChip.svelte +++ b/frontend/src/lib/timeline/BucketHeaderChip.svelte @@ -9,7 +9,8 @@ import * as m from '$lib/paraglide/messages.js'; * label contrast holds in both light and dark themes. A `null` colour — or any value outside the * known token set (the §2 `krieg`/`weih`/`fam` are demo class names, not tokens) — falls back to a * neutral chip with no `var(--c-tag-)` reference, never a broken colour. The name is - * curator/import-derived and rendered through default `{...}` escaping, never `{@html}` (REQ-009). + * curator/import-derived and rendered through default `{...}` escaping, never the raw-HTML + * directive (REQ-009). */ const TAG_COLORS = new Set([ 'sage', diff --git a/frontend/src/lib/timeline/grouping-event-layer-identity.svelte.spec.ts b/frontend/src/lib/timeline/grouping-event-layer-identity.svelte.spec.ts new file mode 100644 index 00000000..a430ebaa --- /dev/null +++ b/frontend/src/lib/timeline/grouping-event-layer-identity.svelte.spec.ts @@ -0,0 +1,93 @@ +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'; +import type { GroupingMode } from './timelineGrouping'; + +afterEach(() => cleanup()); + +const worldBand = (title: string) => + makeEntry({ + kind: 'EVENT', + type: 'HISTORICAL', + derived: false, + precision: 'RANGE', + eventDate: '1914-01-01', + eventDateEnd: '1918-12-31', + eventId: 'h1', + title, + senderName: '', + receiverName: '', + documentId: undefined + }); + +const eventPill = (title: string) => + makeEntry({ + kind: 'EVENT', + type: 'PERSONAL', + derived: false, + eventId: 'p1', + title, + senderName: '', + receiverName: '', + documentId: undefined + }); + +// A signature of the axis-fixed event layer: the curated/world-band titles, the world-range +// marker count, and the event-pill count — everything REQ-001 requires to stay constant when +// only the loose letters re-bundle. (No pixel-diff harness in the repo; this is the structural +// equivalent — the event-layer DOM is byte-for-byte built from the same entries in every mode.) +function eventLayerSignature(): string { + const body = document.body.textContent ?? ''; + return JSON.stringify({ + weltkrieg: body.includes('Erster Weltkrieg'), + hochzeit: body.includes('Hochzeit'), + worldRange: document.querySelectorAll('[data-testid="world-range"]').length + }); +} + +const mixed = () => + makeTimelineDTO({ + years: [ + makeYear(1915, [ + worldBand('Erster Weltkrieg'), + eventPill('Hochzeit'), + makeEntry({ documentId: 'a', title: 'Brief A', linkedEventId: 'h1' }), + makeEntry({ + documentId: 'b', + title: 'Brief B', + rootTagId: 't1', + rootTagName: 'Krieg', + rootTagColor: 'sienna' + }) + ]) + ] + }); + +function signatureFor(mode: GroupingMode): string { + render(TimelineView, { timeline: mixed(), groupingMode: mode }); + const sig = eventLayerSignature(); + cleanup(); + return sig; +} + +describe('TimelineView event layer (REQ-001)', () => { + it('renders the event pills and world-bands identically across all three grouping modes', () => { + const dateSig = signatureFor('date'); + const eventSig = signatureFor('event'); + const themaSig = signatureFor('thema'); + + expect(eventSig).toBe(dateSig); + expect(themaSig).toBe(dateSig); + // sanity: the world-band actually rendered, so the assertion is not vacuously equal on "" + expect(dateSig).toContain('"worldRange":1'); + }); + + it('regroups only the loose letters — buckets appear off Datum, not in it', () => { + render(TimelineView, { timeline: mixed(), groupingMode: 'date' }); + expect(document.querySelector('[data-testid="letter-bucket"]')).toBeNull(); + cleanup(); + render(TimelineView, { timeline: mixed(), groupingMode: 'event' }); + expect(document.querySelector('[data-testid="letter-bucket"]')).not.toBeNull(); + }); +}); diff --git a/frontend/src/lib/timeline/timeline-no-raw-html.spec.ts b/frontend/src/lib/timeline/timeline-no-raw-html.spec.ts new file mode 100644 index 00000000..99b68152 --- /dev/null +++ b/frontend/src/lib/timeline/timeline-no-raw-html.spec.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const timelineDir = dirname(fileURLToPath(import.meta.url)); + +/** + * REQ-009 / CWE-79: the regroup touches every component under lib/timeline (the reused TagChip, + * the .lcard.ev card, and the new tinted bucket-header chip). Curator/import-derived text must + * always render through Svelte's default `{...}` escaping — never `{@html}`. This grep gate fails + * loudly the moment any timeline component reaches for the raw-HTML directive. + */ +describe('lib/timeline never uses {@html} (REQ-009)', () => { + it('no timeline component contains the raw-HTML directive', () => { + const components = readdirSync(timelineDir).filter((file) => file.endsWith('.svelte')); + expect(components.length).toBeGreaterThan(0); + const offenders = components.filter((file) => + readFileSync(join(timelineDir, file), 'utf8').includes('{@html') + ); + expect(offenders).toEqual([]); + }); +}); -- 2.49.1 From 38250606d93fc2b252b1e1754d7fd6465995208b Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 11:02:59 +0200 Subject: [PATCH 12/25] test(timeline): add the e2e grouping spec (zero-fetch, 320px, axe) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the #780 layer-filter e2e for #827: switching Datum/Ereignis/Thema issues zero extra GET /api/timeline (REQ-002), the control stays overflow-free and ≥44px with full-word aria-labels at 320px (REQ-011), and a 320px axe pass holds in light and dark mode (REQ-010g). Local-only like the filter e2e (E2E is not yet in CI). Refs #827 Co-Authored-By: Claude Opus 4.8 --- frontend/e2e/zeitstrahl-grouping.spec.ts | 123 +++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 frontend/e2e/zeitstrahl-grouping.spec.ts diff --git a/frontend/e2e/zeitstrahl-grouping.spec.ts b/frontend/e2e/zeitstrahl-grouping.spec.ts new file mode 100644 index 00000000..d49de9cf --- /dev/null +++ b/frontend/e2e/zeitstrahl-grouping.spec.ts @@ -0,0 +1,123 @@ +import AxeBuilder from '@axe-core/playwright'; +import { test, expect, type APIRequestContext } from '@playwright/test'; + +/** + * Global /zeitstrahl grouping toggle (#827). Runs against the real stack with the seeded admin + * session (auth.setup). Covers REQ-002 (switching modes issues zero extra GET /api/timeline + * requests — the regroup is client-side), REQ-011 (the control stays usable and overflow-free at + * 320px with full-word aria-labels and ≥44px tap targets), and REQ-010g (a 320px axe pass over + * the control in both light and dark mode). + * + * Per e2e/CLAUDE.md, E2E is not yet wired into CI — this gate runs locally for now, like the + * #780 layer-filter spec it mirrors. + */ + +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 so the timeline has a loose letter and the grouping control is enabled. */ +async function seedDatedLetter(request: APIRequestContext, isoDate: string, title: string) { + const senderId = await createPerson(request, 'Group-Test', `Absender ${stamp()}`); + const receiverId = await createPerson(request, 'Group-Test', `Empfaenger ${stamp()}`); + + const createRes = await request.post('/api/documents', { multipart: { title } }); + 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, + documentDate: isoDate, + metaDatePrecision: 'DAY', + senderId, + receiverIds: receiverId + } + }); + if (!put.ok()) throw new Error(`update document failed: ${put.status()}`); +} + +test.describe('Zeitstrahl — grouping toggle (#827)', () => { + test('switching grouping modes issues no extra timeline fetch (REQ-002)', async ({ + page, + request + }) => { + await seedDatedLetter(request, '1909-05-05', `E2E Group Brief ${stamp()}`); + + let timelineRequests = 0; + page.on('request', (req) => { + if (req.url().includes('/api/timeline')) timelineRequests++; + }); + + await page.goto('/zeitstrahl'); + await page.waitForSelector('[data-hydrated]'); + await expect(page.getByTestId('grouping-control')).toBeVisible(); + + const afterLoad = timelineRequests; + await page.locator('[data-value="event"]').click(); + await page.locator('[data-value="thema"]').click(); + await page.locator('[data-value="date"]').click(); + + // the regroup is a pure client-side transform — not one more GET /api/timeline + expect(timelineRequests).toBe(afterLoad); + }); + + test('the control stays overflow-free and operable at 320px (REQ-011)', async ({ + page, + request + }) => { + await seedDatedLetter(request, '1911-02-02', `E2E Group 320 ${stamp()}`); + + await page.setViewportSize({ width: 320, height: 800 }); + await page.goto('/zeitstrahl'); + await page.waitForSelector('[data-hydrated]'); + + const control = page.getByTestId('grouping-control'); + await expect(control).toBeVisible(); + + // the control fits inside the 320px viewport — no horizontal overflow + const box = await control.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.x + box!.width).toBeLessThanOrEqual(321); + + for (const [value, fullWord] of [ + ['date', 'Datum'], + ['event', 'Ereignis'], + ['thema', 'Thema'] + ]) { + const radio = page.locator(`[data-value="${value}"]`); + const radioBox = await radio.boundingBox(); + expect(radioBox!.height).toBeGreaterThanOrEqual(44); + expect(radioBox!.width).toBeGreaterThanOrEqual(44); + // the abbreviated segment still announces its full word + expect(await radio.getAttribute('aria-label')).toBe(fullWord); + } + }); + + test('no wcag2a/wcag2aa violations on the grouping control at 320px (light + dark) (REQ-010g)', async ({ + page, + request + }) => { + await seedDatedLetter(request, '1915-06-15', `E2E Group A11y ${stamp()}`); + + await page.setViewportSize({ width: 320, height: 800 }); + await page.goto('/zeitstrahl'); + await page.waitForSelector('[data-hydrated]'); + await expect(page.getByTestId('grouping-control')).toBeVisible(); + + const scan = () => new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze(); + + const light = await scan(); + expect(light.violations, JSON.stringify(light.violations, null, 2)).toEqual([]); + + await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark')); + const dark = await scan(); + expect(dark.violations, JSON.stringify(dark.violations, null, 2)).toEqual([]); + }); +}); -- 2.49.1 From 9551bbd1ca9c4a89ac5aad411e58852a57dca92c Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 11:07:43 +0200 Subject: [PATCH 13/25] test(i18n): assert the #827 grouping + bucket keys exist in every locale REQ-012 coverage: the new grouping/segment/bucket keys are present in de/en/es and the pre-existing timeline_grouping_date / timeline_tag_chip_label are reused, not re-declared. Refs #827 Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/messages.spec.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/frontend/src/lib/messages.spec.ts b/frontend/src/lib/messages.spec.ts index 155ae1a3..22f1527b 100644 --- a/frontend/src/lib/messages.spec.ts +++ b/frontend/src/lib/messages.spec.ts @@ -133,4 +133,33 @@ describe('message key parity', () => { expect(es, `missing key in es: ${key}`).toHaveProperty(key); } }); + + // #827 REQ-012: the grouping toggle + bucket strings are new Paraglide keys in + // every locale; the pre-existing timeline_grouping_date / timeline_tag_chip_label / + // timeline_filter_* set is reused, never re-added. + it('zeitstrahl grouping + bucket keys are present in all locales (#827 REQ-012)', () => { + const requiredKeys = [ + 'timeline_grouping_event', + 'timeline_grouping_thema', + 'timeline_grouping_aria_label', + 'timeline_grouping_segment_date', + 'timeline_grouping_segment_event', + 'timeline_grouping_segment_thema', + 'timeline_grouping_segment_date_short', + 'timeline_grouping_segment_event_short', + 'timeline_grouping_segment_thema_short', + 'timeline_grouping_disabled_reason', + 'timeline_grouping_multitag_hint', + 'timeline_bucket_other_letters', + 'timeline_bucket_no_topic' + ]; + for (const key of requiredKeys) { + expect(de, `missing key in de: ${key}`).toHaveProperty(key); + expect(en, `missing key in en: ${key}`).toHaveProperty(key); + expect(es, `missing key in es: ${key}`).toHaveProperty(key); + } + // the pre-existing meta-line + chip keys are reused by #827, not re-declared + expect(de).toHaveProperty('timeline_grouping_date'); + expect(de).toHaveProperty('timeline_tag_chip_label'); + }); }); -- 2.49.1 From b54a35322b26f4cff54723d167b4ffa25642e557 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 11:08:02 +0200 Subject: [PATCH 14/25] docs(adr): ADR-045 + RTM rows for the #827 grouping modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the three #827 forks (client-side regroup transport, computed letter→event link reusing timeline_event_documents, filter-then-group composition with #780) as ADR-045, trace REQ-001..019 (+005b) into the RTM as Done, and list the new timeline components in the frontend domain inventory. Refs #827 Co-Authored-By: Claude Opus 4.8 --- .specify/rtm.md | 61 ++++++++++----- docs/adr/045-timeline-client-side-regroup.md | 78 ++++++++++++++++++++ frontend/CLAUDE.md | 2 +- 3 files changed, 120 insertions(+), 21 deletions(-) create mode 100644 docs/adr/045-timeline-client-side-regroup.md diff --git a/.specify/rtm.md b/.specify/rtm.md index b7b8e54d..65bff58e 100644 --- a/.specify/rtm.md +++ b/.specify/rtm.md @@ -2,7 +2,7 @@ > Living document. One row per `REQ-NNN` across all in-flight and shipped features. The spec > itself lives in the **Gitea issue** (issue-only — there is no committed `spec.md`); this -> matrix is the part of the spec that *is* committed: it links each requirement to its issue, +> matrix is the part of the spec that _is_ committed: it links each requirement to its issue, > the code that implements it, and the test(s) that prove it — so any requirement traces end > to end, and any orphan (a requirement with no test) is visible on `main`. @@ -24,30 +24,31 @@ ## Matrix -| REQ-ID | Requirement Summary | Issue | Feature | Implementation File(s) | Test(s) | Status | -|---|---|---|---|---|---|---| -| REQ-001 | Store avatar at `avatars/{userId}`, overwrite | #example | profile-picture-upload (_example) | `UserService` (planned) | `UserServiceAvatarTest#storesUnderUserKey`, `#replaceLeavesNoOrphan` | Planned | -| REQ-002 | Upload self avatar → 200 + avatarUrl | #example | profile-picture-upload (_example) | `UserAvatarController`, `UserService` (planned) | `UserAvatarControllerTest#uploadReturnsAvatarUrl` | Planned | -| REQ-003 | Delete self avatar → avatarUrl null | #example | profile-picture-upload (_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#deleteClearsAvatar` | Planned | -| REQ-004 | No avatar → null + initials placeholder | #example | profile-picture-upload (_example) | `UserProfileView`, avatar component (planned) | `avatar-placeholder.svelte.spec.ts` | Planned | -| REQ-005 | ADMIN_USER may delete others' avatar | #example | profile-picture-upload (_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#adminDeletesOthersAvatar` | Planned | -| REQ-006 | Unauthenticated → 401, store nothing | #example | profile-picture-upload (_example) | `SecurityConfig`, controller (planned) | `UserAvatarControllerTest#unauthenticatedReturns401` | Planned | -| REQ-007 | Non-image → 400 UNSUPPORTED_FILE_TYPE | #example | profile-picture-upload (_example) | `UserService` (planned) | `UserAvatarControllerTest#rejectsNonImage` | Planned | -| REQ-008 | Over 2 MB → 400 AVATAR_TOO_LARGE | #example | profile-picture-upload (_example) | `UserService`, `ErrorCode` (planned) | `UserAvatarControllerTest#rejectsOversize` | Planned | -| REQ-009 | Non-admin on others → 403 FORBIDDEN | #example | profile-picture-upload (_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#nonAdminForbiddenOnOthers` | Planned | -| REQ-001 | Render dated entry via shared `formatDocumentDate` (de/en/es) | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` › `renders a DAY date localized in German`, `renders a SEASON date with the German season word`, `delegates a same-year RANGE to formatDocumentDate` | Done | -| REQ-002 | Non-UNKNOWN + non-empty date → shared label, `raw=null`, `getLocale()` | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` › `renders a DAY date localized in German`, `renders a DAY date localized in English` | Done | -| REQ-003 | `UNKNOWN` → `null` (no chip) | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` › `returns null for UNKNOWN precision even with a date`, `returns null for UNKNOWN precision without a date` | Done | -| REQ-004 | `null`/`undefined`/`''` eventDate → `null`, no formatter call | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` › `returns null for APPROX with a null eventDate, without calling the formatter`, `returns null for DAY with an empty-string eventDate`, `treats undefined eventDateEnd identically to null for RANGE` | Done | -| REQ-005 | No rendering logic outside `documentDate.ts` | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` (façade) | `dateLabel.spec.ts` › `delegates a same-year RANGE to formatDocumentDate` (asserts byte-identical delegation) | Done | -| REQ-006 | `timeline` in coverage `include` (80% branch gate) | #778 | timeline-date-label | `frontend/vite.config.ts` | coverage `include` now lists `src/lib/timeline/**`; covered by all `dateLabel.spec.ts` cases | Done | +| REQ-ID | Requirement Summary | Issue | Feature | Implementation File(s) | Test(s) | Status | +| ------- | ---------------------------------------------------------------------- | -------- | ---------------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| REQ-001 | Store avatar at `avatars/{userId}`, overwrite | #example | profile-picture-upload (\_example) | `UserService` (planned) | `UserServiceAvatarTest#storesUnderUserKey`, `#replaceLeavesNoOrphan` | Planned | +| REQ-002 | Upload self avatar → 200 + avatarUrl | #example | profile-picture-upload (\_example) | `UserAvatarController`, `UserService` (planned) | `UserAvatarControllerTest#uploadReturnsAvatarUrl` | Planned | +| REQ-003 | Delete self avatar → avatarUrl null | #example | profile-picture-upload (\_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#deleteClearsAvatar` | Planned | +| REQ-004 | No avatar → null + initials placeholder | #example | profile-picture-upload (\_example) | `UserProfileView`, avatar component (planned) | `avatar-placeholder.svelte.spec.ts` | Planned | +| REQ-005 | ADMIN_USER may delete others' avatar | #example | profile-picture-upload (\_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#adminDeletesOthersAvatar` | Planned | +| REQ-006 | Unauthenticated → 401, store nothing | #example | profile-picture-upload (\_example) | `SecurityConfig`, controller (planned) | `UserAvatarControllerTest#unauthenticatedReturns401` | Planned | +| REQ-007 | Non-image → 400 UNSUPPORTED_FILE_TYPE | #example | profile-picture-upload (\_example) | `UserService` (planned) | `UserAvatarControllerTest#rejectsNonImage` | Planned | +| REQ-008 | Over 2 MB → 400 AVATAR_TOO_LARGE | #example | profile-picture-upload (\_example) | `UserService`, `ErrorCode` (planned) | `UserAvatarControllerTest#rejectsOversize` | Planned | +| REQ-009 | Non-admin on others → 403 FORBIDDEN | #example | profile-picture-upload (\_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#nonAdminForbiddenOnOthers` | Planned | +| REQ-001 | Render dated entry via shared `formatDocumentDate` (de/en/es) | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` › `renders a DAY date localized in German`, `renders a SEASON date with the German season word`, `delegates a same-year RANGE to formatDocumentDate` | Done | +| REQ-002 | Non-UNKNOWN + non-empty date → shared label, `raw=null`, `getLocale()` | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` › `renders a DAY date localized in German`, `renders a DAY date localized in English` | Done | +| REQ-003 | `UNKNOWN` → `null` (no chip) | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` › `returns null for UNKNOWN precision even with a date`, `returns null for UNKNOWN precision without a date` | Done | +| REQ-004 | `null`/`undefined`/`''` eventDate → `null`, no formatter call | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` › `returns null for APPROX with a null eventDate, without calling the formatter`, `returns null for DAY with an empty-string eventDate`, `treats undefined eventDateEnd identically to null for RANGE` | Done | +| REQ-005 | No rendering logic outside `documentDate.ts` | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` (façade) | `dateLabel.spec.ts` › `delegates a same-year RANGE to formatDocumentDate` (asserts byte-identical delegation) | Done | +| REQ-006 | `timeline` in coverage `include` (80% branch gate) | #778 | timeline-date-label | `frontend/vite.config.ts` | coverage `include` now lists `src/lib/timeline/**`; covered by all `dateLabel.spec.ts` cases | Done | + | REQ-001 | family-member Person with birthDate → 1 BIRTH event, precision passed through | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents` | `DerivedEventsAssemblyTest#should_emit_one_geburt_for_person_with_birthdate`, `#should_pass_birth_precision_through_unchanged` | Done | | REQ-002 | family-member Person with deathDate → 1 DEATH event | #776 | derive-person-life-events | `timeline/TimelineEventService#buildDeathEvents` | `DerivedEventsAssemblyTest#should_emit_one_tod_for_person_with_deathdate`, `#should_emit_one_tod_and_zero_geburt_for_person_with_deathdate_only` | Done | | REQ-003 | null birthDate → 0 Geburt events | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents` | `DerivedEventsAssemblyTest#should_emit_zero_tod_when_person_has_birthdate_but_no_deathdate`, `#should_emit_one_tod_and_zero_geburt_for_person_with_deathdate_only` | Done | | REQ-004 | null deathDate → 0 Tod events; no dates → 0 events | #776 | derive-person-life-events | `timeline/TimelineEventService#buildDeathEvents` | `DerivedEventsAssemblyTest#should_emit_no_events_for_person_with_neither_date` | Done | -| REQ-005 | SPOUSE_OF edge with fromYear → MARRIAGE event, eventDate={fromYear}-01-01, precision=YEAR | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_one_heirat_for_spouse_edge_with_fromYear` | Done | +| REQ-005 | SPOUSE*OF edge with fromYear → MARRIAGE event, eventDate={fromYear}-01-01, precision=YEAR | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_one_heirat_for_spouse_edge_with_fromYear` | Done | | REQ-006 | SPOUSE_OF edge with null fromYear → MARRIAGE emitted, eventDate=null, precision=UNKNOWN | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_unknown_precision_heirat_when_fromYear_is_null` | Done | | REQ-007 | exactly one MARRIAGE per SPOUSE_OF row (dedup on relationship id) | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_exactly_one_heirat_when_both_spouses_in_scope`, `#should_emit_two_heirat_for_person_married_to_two_partners` | Done | | REQ-008 | synthetic prefixed ids (birth:/death:/marriage:), never parseable as UUID | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents,#buildDeathEvents,#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_mint_prefixed_synthetic_ids_never_uuid` | Done | @@ -182,7 +183,7 @@ | REQ-007 | sticky "Filter (N aktiv)" trigger; N from isDefaultState/hiddenLayerCount; 44px target | #780 | timeline-layer-filter | `frontend/src/lib/timeline/timelineFilter.ts`, `frontend/src/lib/timeline/TimelineFilters.svelte` | `timelineFilter.spec.ts#isDefaultState`, `#hiddenLayerCount`; `TimelineFilters.svelte.spec.ts#shows a plain trigger ... and a count`, `#gives the trigger a 44px touch target` | Done | | REQ-008 | reset text button restores all layers on; visibility tracks a $derived any-layer-off flag | #780 | timeline-layer-filter | `frontend/src/lib/timeline/TimelineFilters.svelte` | `TimelineFilters.svelte.spec.ts#hides the reset button by default and restores all layers when activated` | Done | | REQ-009 | prefers-reduced-motion → slide duration 0 (matchMedia guard reused from documents/[id]/+page.svelte:57) | #780 | timeline-layer-filter | `frontend/src/lib/timeline/TimelineFilters.svelte` | manual reduced-motion check + svelte-autofixer/code review (guard reused, only the slide `duration` zeroed) | Done | -| REQ-010 | 8 timeline_filter_* keys in de/en/es; trigger vs trigger_active({count}) distinct | #780 | timeline-layer-filter | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl layer-filter keys are present in all locales (#780 REQ-010)` | Done | +| REQ-010 | 8 timeline_filter*_ keys in de/en/es; trigger vs trigger*active({count}) distinct | #780 | timeline-layer-filter | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl layer-filter keys are present in all locales (#780 REQ-010)` | Done | | REQ-001 | WRITE_ALL viewer → "Ereignis hinzufügen" link to /zeitstrahl/events/new in the wrapping Zeitstrahl header | #842 | timeline-curator-affordances | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/messages/{de,en,es}.json` | `zeitstrahl/page.svelte.spec.ts#renders the add-event CTA in a wrapping header when the viewer can write` | Done | | REQ-002 | viewer without WRITE_ALL → no add-event affordance on /zeitstrahl | #842 | timeline-curator-affordances | `frontend/src/routes/zeitstrahl/+page.svelte` | `zeitstrahl/page.svelte.spec.ts#renders no add-event CTA when the viewer cannot write` | Done | | REQ-003 | WRITE_ALL viewer → person-page "Ereignis für diese Person" link to /zeitstrahl/events/new?personId={id} | #842 | timeline-curator-affordances | `frontend/src/routes/persons/[id]/PersonCard.svelte`, `frontend/messages/{de,en,es}.json` | `PersonCard.svelte.spec.ts#shows an add-event link pre-seeded with the person to a curator` | Done | @@ -194,3 +195,23 @@ | REQ-009 | TimelineView threads canWrite through the year-band path and the undated bucket (identical gate) | #842 | timeline-curator-affordances | `frontend/src/lib/timeline/TimelineView.svelte`, `frontend/src/lib/timeline/YearBand.svelte` | `TimelineView.svelte.spec.ts#threads canWrite to a curated event in both a year band and the undated bucket`, `#threads canWrite to a curated HISTORICAL world band in both paths` | Done | | REQ-010 | person add-event opens #781 create form prefilled with the person, returns to /persons/{id} on save | #842 | timeline-curator-affordances | `frontend/src/routes/persons/[id]/PersonCard.svelte`, `frontend/src/routes/zeitstrahl/events/new/+page.server.ts` (#781) | `PersonCard.svelte.spec.ts#shows an add-event link pre-seeded with the person to a curator` (URL), `zeitstrahl/events/new/page.server.spec.ts#redirects to /persons/{id} when originPersonId is a valid UUID` (#781) | Done | | REQ-011 | non-curator direct nav to /zeitstrahl/events/new or /{id}/edit → 403 (existing #781 route guard, regression) | #842 | timeline-curator-affordances | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts`, `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (#781, unchanged — both share `requireWriteAll`) | `zeitstrahl/events/new/page.server.spec.ts#throws 403 for an authenticated user without WRITE_ALL`, `zeitstrahl/events/[id]/edit/page.server.spec.ts#throws 403 for a user without WRITE_ALL` | Done | +| REQ-001 | axis-fixed layers (life-events, pills, world-bands) render identically across all 3 modes; only loose letters re-bundle | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/TimelineView.svelte`, `frontend/src/lib/timeline/YearBand.svelte` | `grouping-event-layer-identity.svelte.spec.ts#renders the event pills and world-bands identically across all three grouping modes`, `YearBand.svelte.spec.ts#still renders the event world-band in Ereignis mode` | Done | +| REQ-002 | mode switch re-bundles loose letters over the layer-filtered view, no GET /api/timeline refetch | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/timelineGrouping.ts`, `frontend/src/lib/timeline/TimelineView.svelte` | `zeitstrahl/page.svelte.spec.ts#regroups loose letters under their event client-side`, `e2e/zeitstrahl-grouping.spec.ts#switching grouping modes issues no extra timeline fetch` | Done | +| REQ-003 | Ereignis clusters each loose letter under the curated event whose documents contain it | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts#bucketLetters`, `frontend/src/lib/timeline/YearBand.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte` | `timelineGrouping.spec.ts#clusters letters under the curated event named by linkedEventId`, `YearBand.svelte.spec.ts#clusters loose letters under their linked event in Ereignis mode` | Done | +| REQ-004 | Thema buckets each loose letter per year under its primary root tag (rootTagId) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts#bucketLetters`, `frontend/src/lib/timeline/YearBand.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte` | `timelineGrouping.spec.ts#buckets letters under their primary root tag with name and colour`, `YearBand.svelte.spec.ts#buckets loose letters under their root tag in Thema mode` | Done | +| REQ-005 | TimelineEntryDTO carries nullable linkedEventId, resolved in one batched membership pass | #827 | timeline-grouping-modes | `backend/.../timeline/TimelineEntryDTO.java`, `backend/.../timeline/TimelineService.java#resolveLetterEventLinks`, `frontend/src/lib/generated/api.ts` | `TimelineServiceTest#letter_in_a_curated_events_documents_carries_that_events_id` | Done | +| REQ-005b | linkedEventId is nullable / not @Schema REQUIRED; null for non-letter entries | #827 | timeline-grouping-modes | `backend/.../timeline/TimelineEntryDTO.java`, `frontend/src/lib/generated/api.ts` (`linkedEventId?`) | `TimelineServiceTest#letter_in_no_curated_event_has_null_linkedEventId` | Done | +| REQ-006 | Ereignis: letter with null linkedEventId → per-year "Weitere Briefe" bucket | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts`, `frontend/src/lib/timeline/LetterBucket.svelte` | `timelineGrouping.spec.ts#drops a letter with no linkedEventId into the fallback bucket`, `LetterBucket.svelte.spec.ts#uses the localized "Weitere Briefe" label` | Done | +| REQ-007 | Thema: untagged letter → per-year "Ohne Thema" bucket | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts`, `frontend/src/lib/timeline/LetterBucket.svelte` | `timelineGrouping.spec.ts#drops an untagged letter into the "Ohne Thema" fallback bucket`, `LetterBucket.svelte.spec.ts#uses the localized "Ohne Thema" label` | Done | +| REQ-008 | multi-tagged letter appears under exactly one root tag, never duplicated | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts` | `timelineGrouping.spec.ts#places a letter in exactly one bucket` | Done | +| REQ-009 | tag names + hint render via `{...}` escaping; grep gate forbids `{@html}` in lib/timeline | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/BucketHeaderChip.svelte`, `frontend/src/lib/timeline/LetterCard.svelte`, `frontend/src/lib/timeline/TagChip.svelte` | `BucketHeaderChip.svelte.spec.ts#renders an HTML-bearing name as inert text`, `timeline-no-raw-html.spec.ts#no timeline component contains the raw-HTML directive` | Done | +| REQ-010 | grouping control is a keyboard-navigable role=radiogroup, ≥44px text segments, default Datum, dark-mode contrast | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/GroupingControl.svelte` | `GroupingControl.svelte.spec.ts#renders three radios inside a radiogroup`, `#moves the selection forward with the right arrow key`, `#each segment has a tap target of at least 44×44px`, `#defaults to Datum`; `e2e/zeitstrahl-grouping.spec.ts#no wcag2a/wcag2aa violations ... (light + dark)` | Done | +| REQ-011 | ≤320px: control overflow-free + tap ≥44px, each abbreviation carries its full word as aria-label | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/GroupingControl.svelte` | `GroupingControl.svelte.spec.ts#exposes each segment full word as an aria-label`, `e2e/zeitstrahl-grouping.spec.ts#the control stays overflow-free and operable at 320px` | Done | +| REQ-012 | new grouping/bucket Paraglide keys in de/en/es; no collision with existing timeline*_ keys | #827 | timeline-grouping-modes | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl grouping + bucket keys are present in all locales (#827 REQ-012)`, `messages.spec.ts#de, en, and es have identical key sets` | Done | +| REQ-013 | failed timeline fetch → existing localized error via getErrorMessage; grouping has no independent failure mode | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.server.ts` (#779, unchanged) | `zeitstrahl/page.server` error path (#779 — getErrorMessage(extractErrorCode)) | Done | +| REQ-014 | Ereignis event-clustered letter renders as the `.lcard.ev` variant | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterCard.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte` | `LetterCard.svelte.spec.ts#carries the .lcard.ev class in the event variant`, `LetterBucket.svelte.spec.ts#renders its letters as .lcard.ev event cards` | Done | +| REQ-015 | Thema bucket-header chip tinted via rootTagColor `var(--c-tag-*)`, neutral fallback when null/unknown | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/BucketHeaderChip.svelte` | `BucketHeaderChip.svelte.spec.ts#tints the chip with var(--c-tag-{token})`, `#renders a neutral chip with no --c-tag- binding when colour is null`, `#falls back to neutral for an unknown colour token` | Done | +| REQ-016 | header meta-line grouping segment tracks the active mode (date/event/thema keys) | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.svelte` | `zeitstrahl/page.svelte.spec.ts#updates the meta-line grouping label when a mode is chosen` | Done | +| REQ-017 | Thema: per-letter TagChip suppressed inside its own bucket; still shown in Datum/Ereignis | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterCard.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte` | `LetterCard.svelte.spec.ts#suppresses the per-letter tag chip when asked`, `#still shows the per-letter tag chip when not suppressed`, `LetterBucket.svelte.spec.ts#suppresses the per-letter tag chip inside its own root-tag bucket` | Done | +| REQ-018 | Letters layer off → grouping control disabled (kept in place), mode retained | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/GroupingControl.svelte`, `frontend/src/lib/timeline/timelineGrouping.ts#hasLooseLetters` | `zeitstrahl/page.svelte.spec.ts#disables the grouping control when the Letters layer is off`, `GroupingControl.svelte.spec.ts#retains the active mode while disabled`, `timelineGrouping.spec.ts#hasLooseLetters` | Done | +| REQ-019 | Ereignis: letter whose only linking event was filtered off → "Weitere Briefe" (never re-introduced) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts#buildEventLookup`, `frontend/src/lib/timeline/TimelineView.svelte` | `timelineGrouping.spec.ts#drops a letter whose linked event is absent from the lookup into fallback` | Done | diff --git a/docs/adr/045-timeline-client-side-regroup.md b/docs/adr/045-timeline-client-side-regroup.md new file mode 100644 index 00000000..87ba6abd --- /dev/null +++ b/docs/adr/045-timeline-client-side-regroup.md @@ -0,0 +1,78 @@ +# ADR-045 — The /zeitstrahl Ereignis/Thema regroup is client-side, over a computed letter→event link + +**Status:** Accepted +**Date:** 2026-06-15 +**Issue:** #827 (Zeitstrahl milestone; deferred follow-up to #779, builds on #835/PR #838 and #780) + +## Context + +#779 shipped `/zeitstrahl` in **Datum** mode only and deferred the Concept-A +**Datum · Ereignis · Thema** segmented control, because the other two modes need data the +`TimelineEntryDTO` did not carry: a letter's curated-event association (Ereignis) and a letter's +primary root tag + colour (Thema). #835 (merged in PR #838) added the Thema fields +(`rootTagId`/`rootTagName`/`rootTagColor`) and the batched `TimelineService → TagService` +resolver. Meanwhile #780 added the **layer filter** — `/zeitstrahl/+page.svelte` owns +`personalOn`/`historicalOn`/`lettersOn` `$state` and renders `TimelineView` over a client-side +`filterTimeline(data.timeline, …)` view. + +This ADR records the three forks specific to **#827** (the Thema enrichment + the +`TimelineService → TagService` edge are #835's scope, not this one). + +## Decisions + +### 1. Grouping is a client-side presentation transform — no `grouping=` query param + +`GET /api/timeline` already returns the whole timeline in one payload. Regrouping the loose +letters is an in-memory transform in `lib/timeline/timelineGrouping.ts` (`bucketLetters`, +`buildEventLookup`, `hasLooseLetters`), driven by a `groupingMode` `$state` in `+page.svelte`. +A server-side `grouping=DATE|EVENT|TOPIC` parameter was rejected: it would add lasting API +surface and a bucket query for zero benefit on an already-loaded payload, and switching modes +must issue **zero** extra fetches (REQ-002). The blast radius stays inside the read view. + +### 2. The letter→event link is computed, reusing `timeline_event_documents` — no new column + +A letter clusters under a curated event iff that event's `documents` set (ADR-040; +`@ManyToMany @BatchSize(50)` over join table `timeline_event_documents`) contains the letter's +document. `TimelineService.assemble` resolves this in **one batched membership pass** — +`resolveLetterEventLinks` builds a single `docId → eventId` map over the already-loaded events +(no per-letter query), reusing the same `eventRepository.findAll()` it already iterates for the +event entries. The result is exposed as one nullable DTO field, `linkedEventId`. A new persisted +FK on the document/letter row was rejected: it duplicates an existing capability and opens a +mutating write path + Flyway migration for no gain. **No new column, no migration, no new +cross-domain edge** (the field derives from data `TimelineService` already loads). `linkedEventId` +is deliberately **not** `@Schema(requiredMode = REQUIRED)` — it is null for non-letter entries and +for letters under no curated event — so the generated TypeScript type stays optional. + +### 3. Grouping composes with the #780 layer filter as **filter-then-group** + +The pipeline is `data.timeline → filterTimeline() (#780) → groupingMode transform → TimelineView`. +The grouping `$state` lives in `+page.svelte` beside the filter `$state`, and the regroup runs over +the layer-**filtered** view, never the raw `data.timeline`. Grouping the raw timeline and filtering +afterward was rejected: the counts and buckets would disagree with the layer toggles, re-opening +the #780 count-mismatch the page already closed. Two consequences fall out of filter-then-group: + +- **Letters layer off → the grouping control disables, kept in place (REQ-018).** With no loose + letters in the filtered view there is nothing to regroup; the control renders `aria-disabled` + (no header reflow), keeps its selected mode, and announces a screen-reader reason. +- **A letter whose only linking event was filtered out falls back to "Weitere Briefe" (REQ-019).** + `buildEventLookup` is built from the events present in the _filtered_ view, so Ereignis clusters + only under events that survived the filter; everything else lands in the per-year fallback bucket. + +The control is a `role="radiogroup"` (single-select), deliberately distinct from #780's +`aria-pressed` toggle filter, stacked above the filter trigger so the two read as one control +cluster — the top-right corner stays the #842 add-event CTA. + +## Consequences + +- One nullable field (`linkedEventId`) is added to `TimelineEntryDTO` (17 components); the + regenerated `frontend/src/lib/generated/api.ts` is committed in the same PR. No table, column, + Flyway migration, endpoint, `ErrorCode`, or `Permission` changes. +- The regroup is pure and fully unit-tested independently of the components; `TimelineView`/ + `YearBand` render the axis-fixed event layer identically across all three modes (REQ-001) and + only swap the loose-letter rendering for per-year `LetterBucket`s off Datum. +- The new Thema bucket-header chip (`BucketHeaderChip`) is a filled variant tinted from + `rootTagColor`; the shipped neutral per-letter `TagChip` (#838) is reused as-is and suppressed + inside its own bucket (REQ-017). All `lib/timeline` components keep the `{...}`-escaping + guarantee — a grep gate forbids `{@html}` (REQ-009). +- Read-only feature: no new authn/authz surface beyond the existing `READ_ALL` on + `GET /api/timeline`. diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 17823ad6..1209ad1f 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -50,7 +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/) +│ ├── timeline/ # Timeline (Zeitstrahl) domain: TimelineView, YearBand, EventPill, WorldBand, LetterCard, LetterBucket, BucketHeaderChip, GroupingControl, TagChip, YearLetterStrip, GapSpan; dateLabel + timelineDensity + timelineFilter + timelineGrouping + eventCardConfig (imports $lib/shared only, never document/) │ ├── geschichte/ # Geschichte (story) domain: editor + card │ ├── notification/ # Notification bell + dropdown + store │ ├── activity/ # Activity feed (Chronik) components -- 2.49.1 From dd97418e24838cd59a37ac3aee0b10023ea56954 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 11:54:19 +0200 Subject: [PATCH 15/25] fix(timeline): keep the Thema bucket-header label in a fixed ink, not the tag token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tinted bucket-header chip painted the saturated --c-tag-* token AS its label text over a 18% wash of the same token. For the light tokens that fails WCAG AA: amber ≈3.0:1, sand ≈3.2:1, sage ≈3.4:1 (only sienna, the one the test used, passed). Move the tint to the chip fill + dot and render the label in a fixed dark ink so every token clears 4.5:1 while the chip still reads as tinted. Refs #827 --- .../src/lib/timeline/BucketHeaderChip.svelte | 16 ++++++++++------ .../lib/timeline/BucketHeaderChip.svelte.spec.ts | 13 +++++++++++++ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/timeline/BucketHeaderChip.svelte b/frontend/src/lib/timeline/BucketHeaderChip.svelte index d3fa1561..c5bdeb98 100644 --- a/frontend/src/lib/timeline/BucketHeaderChip.svelte +++ b/frontend/src/lib/timeline/BucketHeaderChip.svelte @@ -28,10 +28,12 @@ const TAG_COLORS = new Set([ let { name, color }: { name: string; color: string | null } = $props(); const token = $derived(color && TAG_COLORS.has(color) ? color : null); +// The tint paints the chip's fill + dot only — never the label text. The saturated +// --c-tag-* tokens used AS text over their own wash drop below WCAG AA 4.5:1 for the +// light tokens (amber ≈3.0, sand ≈3.2, sage ≈3.4); a fixed dark ink keeps every token +// legible while the 18% wash still reads as a genuinely tinted chip (REQ-015). const chipStyle = $derived( - token - ? `background-color: color-mix(in srgb, var(--c-tag-${token}) 14%, transparent); color: var(--c-tag-${token})` - : '' + token ? `background-color: color-mix(in srgb, var(--c-tag-${token}) 18%, transparent)` : '' ); const dotStyle = $derived(token ? `background-color: var(--c-tag-${token})` : ''); @@ -44,7 +46,6 @@ const dotStyle = $derived(token ? `background-color: var(--c-tag-${token})` : '' class:border={!token} class:border-line={!token} class:bg-surface={!token} - class:text-ink-3={!token} > {m.timeline_tag_chip_label()}: - {name}{name} diff --git a/frontend/src/lib/timeline/BucketHeaderChip.svelte.spec.ts b/frontend/src/lib/timeline/BucketHeaderChip.svelte.spec.ts index 61c926ad..24a60ba4 100644 --- a/frontend/src/lib/timeline/BucketHeaderChip.svelte.spec.ts +++ b/frontend/src/lib/timeline/BucketHeaderChip.svelte.spec.ts @@ -41,4 +41,17 @@ describe('BucketHeaderChip (REQ-015/009)', () => { expect(document.body.textContent).toContain(evil); expect(document.querySelector('img')).toBeNull(); }); + + it('paints the label in a fixed ink colour, never the saturated tag token (contrast, REQ-015)', () => { + // A saturated --c-tag-* token used as TEXT over its own wash fails 4.5:1 for the + // light tokens (amber/sand/sage ≈ 3:1). The tint must go to the background + dot; + // the label keeps a guaranteed-contrast ink token. + render(BucketHeaderChip, { name: 'Weihnachten', color: 'amber' }); + const chip = document.querySelector('[data-testid="bucket-header-chip"]') as HTMLElement; + expect(chip.getAttribute('style') ?? '').not.toContain('color: var(--c-tag-'); + const label = document.querySelector('[data-testid="bucket-header-chip-label"]') as HTMLElement; + expect(label.className).toContain('text-ink'); + // still genuinely tinted — the token paints the wash and the dot + expect(document.body.innerHTML).toContain('var(--c-tag-amber)'); + }); }); -- 2.49.1 From ca06293dc53625ffd81c4848806260c9e79a36f9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 11:57:20 +0200 Subject: [PATCH 16/25] feat(timeline): add a compact LetterCard variant for in-bucket letters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grouped-mode buckets stack many letters; the full two-line card with its own date chip floods the view. The compact variant tightens the padding and, when the letter has a title, drops the redundant date chip (the per-year bucket already frames the time and these archive titles embed the date). Datum mode is untouched — compact defaults to false. Refs #827 --- frontend/src/lib/timeline/LetterCard.svelte | 29 +++++++++++++++---- .../lib/timeline/LetterCard.svelte.spec.ts | 18 ++++++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/timeline/LetterCard.svelte b/frontend/src/lib/timeline/LetterCard.svelte index 84a76bfb..df586f79 100644 --- a/frontend/src/lib/timeline/LetterCard.svelte +++ b/frontend/src/lib/timeline/LetterCard.svelte @@ -21,11 +21,21 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO']; let { entry, variant = 'plain', - suppressTagChip = false -}: { entry: TimelineEntryDTO; variant?: 'plain' | 'event'; suppressTagChip?: boolean } = $props(); + suppressTagChip = false, + compact = false +}: { + entry: TimelineEntryDTO; + variant?: 'plain' | 'event'; + suppressTagChip?: boolean; + compact?: boolean; +} = $props(); const isEventVariant = $derived(variant === 'event'); const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd)); +// Inside a per-year bucket the year frames the time, and these archive titles already +// embed the date — so the compact in-bucket card drops the redundant date chip when a +// title is present, halving the row height and killing the duplicate date (#827). +const showDate = $derived(!compact || !entry.title); const sender = $derived(entry.senderName === '' ? m.timeline_unknown_person() : entry.senderName); const receiver = $derived( entry.receiverName === '' ? m.timeline_unknown_person() : entry.receiverName @@ -38,23 +48,30 @@ const receiver = $derived( {#if entry.title} - + {entry.title} {/if} - + {sender} {receiver} - {#if dateLabel} + {#if dateLabel && showDate} · {dateLabel} {/if} diff --git a/frontend/src/lib/timeline/LetterCard.svelte.spec.ts b/frontend/src/lib/timeline/LetterCard.svelte.spec.ts index df36bffc..7821ff7e 100644 --- a/frontend/src/lib/timeline/LetterCard.svelte.spec.ts +++ b/frontend/src/lib/timeline/LetterCard.svelte.spec.ts @@ -151,4 +151,22 @@ describe('LetterCard — grouping variants (#827, REQ-014/017)', () => { render(LetterCard, { entry: makeEntry({ rootTagName: 'Krieg', rootTagColor: 'sienna' }) }); expect(document.querySelector('[data-testid="tag-chip"]')).not.toBeNull(); }); + + it('drops the redundant date line in the compact variant when a title is present (#827)', () => { + // Inside a per-year bucket the year already frames the time, and these archive + // titles embed the date — so the compact in-bucket card omits the date chip. + render(LetterCard, { entry: makeEntry({ title: 'H-0023 – 6. Juli 1916' }), compact: true }); + expect(document.querySelector('[data-testid="letter-date"]')).toBeNull(); + expect(document.body.textContent).toContain('Karl Raddatz'); // sender still shown + }); + + it('keeps the date in the compact variant when the letter has no title (#827)', () => { + render(LetterCard, { entry: makeEntry({ title: undefined }), compact: true }); + expect(document.querySelector('[data-testid="letter-date"]')).not.toBeNull(); + }); + + it('renders the compact variant on a single tighter row (#827)', () => { + render(LetterCard, { entry: makeEntry(), compact: true }); + expect(document.querySelector('a.lcard.compact')).not.toBeNull(); + }); }); -- 2.49.1 From 23534fb077da4af108f46a4b9aae9001aeec0239 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 12:01:47 +0200 Subject: [PATCH 17/25] feat(timeline): contain buckets with a colour rail and collapse oversized ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grouped view flooded: buckets had no visual containment (a tiny floating pill over cards identical to the ungrouped view) and the >12-letter density collapse was gone, so "Weitere Briefe · 325" / "Sonstiges · 10" dumped every card. LetterBucket now binds each cluster with a coloured left rail (tag colour in Thema, mint for an Ereignis cluster, neutral for the fallback), renders compact cards, and — above BUCKET_DENSE_THRESHOLD (6) — collapses to the existing month-density YearLetterStrip instead of a flood. Adds a `nested` mode (no header) for letters that sit under their event pill, and shares the tag-colour token allow-list via tagColorVar. Refs #827 --- frontend/src/lib/timeline/LetterBucket.svelte | 101 ++++++++++++------ .../lib/timeline/LetterBucket.svelte.spec.ts | 62 +++++++++++ frontend/src/lib/timeline/timelineGrouping.ts | 35 ++++++ 3 files changed, 165 insertions(+), 33 deletions(-) diff --git a/frontend/src/lib/timeline/LetterBucket.svelte b/frontend/src/lib/timeline/LetterBucket.svelte index 3b0f986c..58b96595 100644 --- a/frontend/src/lib/timeline/LetterBucket.svelte +++ b/frontend/src/lib/timeline/LetterBucket.svelte @@ -2,48 +2,83 @@ import * as m from '$lib/paraglide/messages.js'; import LetterCard from './LetterCard.svelte'; import BucketHeaderChip from './BucketHeaderChip.svelte'; +import YearLetterStrip from './YearLetterStrip.svelte'; import { entryKey } from './entryKey'; -import type { LetterBucket } from './timelineGrouping'; +import { isBucketDense, tagColorVar, type LetterBucket } from './timelineGrouping'; /** - * One cluster of loose letters under a header, in Ereignis or Thema mode (#827). The axis-fixed - * event/world-band layers are rendered elsewhere — this is only the loose-letter bundling. - * Ereignis: a "✉ · " header over `.lcard.ev` cards (REQ-003/014). Thema: a tinted - * root-tag header chip over cards whose own tag chip is suppressed (REQ-004/015/017). A bucket - * with no title (kind `fallback`) uses the localized "Weitere Briefe"/"Ohne Thema" label and - * keeps its letters as plain cards (REQ-006/007). + * One cluster of loose letters, bound together by a coloured left rail so the group reads as a + * unit (#827). The axis-fixed event/world-band layers are rendered elsewhere — this is only the + * loose-letter bundling. + * + * - Thema: a tinted root-tag header chip + a rail in the tag colour, over compact cards whose own + * tag chip is suppressed (REQ-004/015/017). + * - Ereignis: rendered `nested` directly beneath its event pill — no header (the pill is the + * header), a mint rail, `.lcard.ev` cards (REQ-003/014). The standalone "Weitere Briefe" / + * "Ohne Thema" fallback keeps its label and a neutral rail (REQ-006/007). + * + * A bucket larger than the density threshold collapses to the month-density `YearLetterStrip` + * instead of flooding the timeline with every card (#827) — the catch-all buckets are the biggest. */ -let { bucket, mode }: { bucket: LetterBucket; mode: 'event' | 'thema' } = $props(); +let { + bucket, + mode, + year = 0, + nested = false +}: { bucket: LetterBucket; mode: 'event' | 'thema'; year?: number; nested?: boolean } = $props(); const count = $derived(bucket.letters.length); +const dense = $derived(isBucketDense(count)); const fallbackLabel = $derived( mode === 'event' ? m.timeline_bucket_other_letters() : m.timeline_bucket_no_topic() ); +// The left rail binds the cluster: the tag colour in Thema, mint for an Ereignis cluster, +// neutral for the fallback (and for a colourless/unknown tag token). +const railColor = $derived(bucket.kind === 'tag' ? tagColorVar(bucket.color) : null); +const railStyle = $derived(railColor ? `border-left-color: ${railColor}` : ''); +const isEventCluster = $derived(nested || bucket.kind === 'event'); +const cardVariant = $derived(mode === 'event' && isEventCluster ? 'event' : 'plain'); -
    -
    - {#if mode === 'thema' && bucket.kind === 'tag'} - - {:else if mode === 'event' && bucket.kind === 'event'} - - - {bucket.title} - - {:else} - {fallbackLabel} - {/if} - · {count} -
    -
      - {#each bucket.letters as letter (entryKey(letter))} -
    • - {#if mode === 'event'} - - {:else} - - {/if} -
    • - {/each} -
    +
    + {#if !nested} +
    + {#if mode === 'thema' && bucket.kind === 'tag'} + + {:else if mode === 'event' && bucket.kind === 'event'} + + + {bucket.title} + + {:else} + {fallbackLabel} + {/if} + · {count} +
    + {/if} + + {#if dense} + + + {:else} +
      + {#each bucket.letters as letter (entryKey(letter))} +
    • + +
    • + {/each} +
    + {/if}
    diff --git a/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts b/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts index c024c9f5..22b6b137 100644 --- a/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts +++ b/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts @@ -72,3 +72,65 @@ describe('LetterBucket — Thema mode (REQ-004/007/015/017)', () => { expect(document.body.textContent).toContain(m.timeline_bucket_no_topic()); }); }); + +const manyLetters = (n: number) => + Array.from({ length: n }, (_, i) => + makeEntry({ documentId: `d${i}`, eventDate: `1916-0${(i % 9) + 1}-01` }) + ); + +describe('LetterBucket — density + containment (#827)', () => { + it('collapses an oversized bucket to the density strip instead of flooding cards', () => { + const bucket: Bucket = { + key: 'tag:t1', + kind: 'tag', + title: 'Sonstiges', + color: null, + letters: manyLetters(10) + }; + render(LetterBucket, { bucket, mode: 'thema', year: 1916 }); + expect(document.querySelector('[data-testid="strip-expand"]')).not.toBeNull(); + // not ten individual cards dumped into the timeline + expect(document.querySelectorAll('a.lcard')).toHaveLength(0); + }); + + it('renders compact cards for a small bucket (no strip)', () => { + const bucket: Bucket = { + key: 'tag:t1', + kind: 'tag', + title: 'Tod', + color: null, + letters: manyLetters(2) + }; + render(LetterBucket, { bucket, mode: 'thema', year: 1916 }); + expect(document.querySelector('[data-testid="strip-expand"]')).toBeNull(); + expect(document.querySelectorAll('a.lcard.compact')).toHaveLength(2); + }); + + it('omits the header when nested — the event pill above is the header', () => { + const bucket: Bucket = { + key: 'event:e1', + kind: 'event', + title: 'Ein gewaltiger Stadtbrand', + color: null, + letters: manyLetters(1) + }; + render(LetterBucket, { bucket, mode: 'event', nested: true, year: 1916 }); + expect(document.querySelector('[data-testid="bucket-count"]')).toBeNull(); + expect(document.body.textContent).not.toContain('Ein gewaltiger Stadtbrand'); + // still the event-letter variant, just headerless under its pill + expect(document.querySelector('a.lcard.ev')).not.toBeNull(); + }); + + it('binds a tag bucket together with a coloured left rail from its token', () => { + const bucket: Bucket = { + key: 'tag:t1', + kind: 'tag', + title: 'Krieg', + color: 'sienna', + letters: manyLetters(1) + }; + render(LetterBucket, { bucket, mode: 'thema', year: 1916 }); + const section = document.querySelector('[data-testid="letter-bucket"]') as HTMLElement; + expect(section.getAttribute('style') ?? '').toContain('var(--c-tag-sienna)'); + }); +}); diff --git a/frontend/src/lib/timeline/timelineGrouping.ts b/frontend/src/lib/timeline/timelineGrouping.ts index 13f317ac..c2d745c2 100644 --- a/frontend/src/lib/timeline/timelineGrouping.ts +++ b/frontend/src/lib/timeline/timelineGrouping.ts @@ -14,6 +14,41 @@ export type GroupingMode = 'date' | 'event' | 'thema'; /** The default mode — chronological, as #779 shipped. */ export const DEFAULT_GROUPING: GroupingMode = 'date'; +/** + * A bucket larger than this collapses to a month-density strip instead of flooding the + * timeline with individual cards (#827) — the catch-all "Weitere Briefe"/"Ohne Thema" + * buckets are always the biggest, so without this they swamp the grouped view. Lower than + * Datum mode's `DENSE_THRESHOLD` (12) because a bucket is a narrower context than a year. + */ +export const BUCKET_DENSE_THRESHOLD = 6; + +export function isBucketDense(letterCount: number): boolean { + return letterCount > BUCKET_DENSE_THRESHOLD; +} + +/** The `--c-tag-*` colour-name tokens (sage/sienna/…); shared by the chip and the rail. */ +const TAG_COLOR_TOKENS = new Set([ + 'sage', + 'sienna', + 'amber', + 'slate', + 'violet', + 'rose', + 'cobalt', + 'moss', + 'sand', + 'coral' +]); + +/** + * Maps a root-tag colour-name token to its CSS variable reference, or `null` for an absent + * or unknown token (so a colourless/unrecognised tag falls back to a neutral rail, never a + * broken `var(--c-tag-undefined)`). + */ +export function tagColorVar(token: string | null | undefined): string | null { + return token && TAG_COLOR_TOKENS.has(token) ? `var(--c-tag-${token})` : null; +} + /** * One bundle of loose letters under a single header, within a year (Ereignis/Thema modes). * `kind` decides the header: a curated-event title, a tinted root-tag chip, or the localized -- 2.49.1 From ea1034f9cec1052402cf7e5593326d8e2c633d37 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 12:07:39 +0200 Subject: [PATCH 18/25] feat(timeline): nest Ereignis letters under their event pill, no duplicate title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Ereignis mode the curated event showed twice — once as its axis pill and again as a repeated "✉ " bucket header below. Letters that link to a curated event whose pill is in the same year band now nest directly under that pill (headerless), so the title reads once. A cluster whose pill lives in another band keeps its header, and unlinked letters still fall to the single "Weitere Briefe" bucket. Thema mode is unchanged (tags have no axis pill). REQ-001 holds — the pills render identically. Refs #827 --- frontend/src/lib/timeline/YearBand.svelte | 45 +++++++++++++++---- .../src/lib/timeline/YearBand.svelte.spec.ts | 39 ++++++++++++++++ 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/frontend/src/lib/timeline/YearBand.svelte b/frontend/src/lib/timeline/YearBand.svelte index d60012bc..17f704a4 100644 --- a/frontend/src/lib/timeline/YearBand.svelte +++ b/frontend/src/lib/timeline/YearBand.svelte @@ -43,26 +43,53 @@ type Row = | { t: 'event'; entry: TimelineEntryDTO } | { t: 'letter'; entry: TimelineEntryDTO; side: 'left' | 'right' } | { t: 'strip' } - | { t: 'bucket'; bucket: LetterBucketModel }; + | { t: 'bucket'; bucket: LetterBucketModel; nested: boolean }; const letters = $derived(year.entries.filter((e) => e.kind === 'LETTER')); const dense = $derived(isDense(letters.length)); -const grouped = $derived(groupingMode !== 'date'); const bucketMode = $derived(groupingMode === 'thema' ? 'thema' : 'event'); const rows = $derived.by(() => { const out: Row[] = []; - if (grouped) { - // Events stay on the axis, identical to Datum mode (REQ-001); only the loose - // letters re-bundle into per-year buckets below them (REQ-003/004). + + // Ereignis: events stay on the axis (REQ-001); each curated event's letters nest directly + // beneath its pill — the pill IS the header, so the title is never repeated. A cluster whose + // pill lives in another year band (or was filtered out) keeps its own header here, and the + // unlinked letters fall to the single "Weitere Briefe" bucket (REQ-003/006/019). + if (groupingMode === 'event') { + const buckets = bucketLetters(letters, 'event', eventLookup); + const hasPill = (bucketKey: string) => + year.entries.some((e) => e.kind === 'EVENT' && `event:${e.eventId}` === bucketKey); + // Each pill renders, then its same-year cluster nests directly beneath it (no header). for (const entry of year.entries) { - if (entry.kind === 'EVENT') out.push({ t: 'event', entry }); + if (entry.kind !== 'EVENT') continue; + out.push({ t: 'event', entry }); + const bucket = entry.eventId + ? buckets.find((b) => b.kind === 'event' && b.key === `event:${entry.eventId}`) + : undefined; + if (bucket) out.push({ t: 'bucket', bucket, nested: true }); } - for (const bucket of bucketLetters(letters, bucketMode, eventLookup)) { - out.push({ t: 'bucket', bucket }); + // Clusters whose pill is in another band keep their header; then the fallback, last. + for (const bucket of buckets) { + if (bucket.kind === 'fallback' || !hasPill(bucket.key)) { + out.push({ t: 'bucket', bucket, nested: false }); + } } return out; } + + // Thema: events stay on the axis (REQ-001); loose letters re-bundle into per-year root-tag + // buckets below them (REQ-004) — no axis pill exists for a tag, so every bucket keeps a header. + if (groupingMode === 'thema') { + for (const entry of year.entries) { + if (entry.kind === 'EVENT') out.push({ t: 'event', entry }); + } + for (const bucket of bucketLetters(letters, 'thema', eventLookup)) { + out.push({ t: 'bucket', bucket, nested: false }); + } + return out; + } + let stripInserted = false; let letterIndex = 0; for (const entry of year.entries) { @@ -110,7 +137,7 @@ function rowKey(row: Row): string { {:else if row.t === 'bucket'} - + {:else} {/if} diff --git a/frontend/src/lib/timeline/YearBand.svelte.spec.ts b/frontend/src/lib/timeline/YearBand.svelte.spec.ts index 2481c6a2..9f2a8ece 100644 --- a/frontend/src/lib/timeline/YearBand.svelte.spec.ts +++ b/frontend/src/lib/timeline/YearBand.svelte.spec.ts @@ -221,4 +221,43 @@ describe('YearBand — grouping modes (#827)', () => { const chip = document.querySelector('[data-testid="bucket-header-chip"]'); expect(chip?.textContent).toContain('Krieg'); }); + + it('nests an event cluster under its pill in the same year without repeating the title (#827)', () => { + const pill = makeEntry({ + kind: 'EVENT', + type: 'PERSONAL', + derived: false, + eventId: 'e1', + title: 'Ein gewaltiger Stadtbrand', + eventDate: '1916-07-06', + senderName: '', + receiverName: '', + documentId: undefined + }); + const letter = makeEntry({ documentId: 'a', linkedEventId: 'e1', eventDate: '1916-07-06' }); + render(YearBand, { + year: makeYear(1916, [pill, letter]), + groupingMode: 'event', + eventLookup: new Map([['e1', 'Ein gewaltiger Stadtbrand']]) + }); + // the title appears exactly once — on the axis pill, NOT also as a bucket header + const occurrences = + (document.body.textContent ?? '').split('Ein gewaltiger Stadtbrand').length - 1; + expect(occurrences).toBe(1); + // the letter is still clustered (nested under the pill) as the event-letter card + expect(document.querySelector('[data-testid="letter-bucket"]')).not.toBeNull(); + expect(document.querySelector('a.lcard.ev')).not.toBeNull(); + }); + + it('keeps a header on an event cluster whose pill is in another year (#827)', () => { + // the letter links to e1, but e1's pill lives in a different band — so the cluster + // keeps its own header here (no pill nearby to duplicate). + const letter = makeEntry({ documentId: 'a', linkedEventId: 'e1', eventDate: '1917-02-01' }); + render(YearBand, { + year: makeYear(1917, [letter]), + groupingMode: 'event', + eventLookup: new Map([['e1', 'Briefe von der Front']]) + }); + expect(document.body.textContent).toContain('Briefe von der Front'); + }); }); -- 2.49.1 From ce6afd3bd0cd30e7ed71f1788866befd4a57386e Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 12:11:38 +0200 Subject: [PATCH 19/25] docs(rtm): trace the #827 grouped-view redesign (nesting, contrast, density) REQ-014 now nests event-clustered letters under their pill (no duplicate title); REQ-015 keeps the bucket-header label in a fixed ink for AA contrast; new REQ-020 records the colour-rail containment + density-strip collapse that replaces the flooding flat card list. Refs #827 #847 --- .specify/rtm.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.specify/rtm.md b/.specify/rtm.md index 65bff58e..bc7e68a5 100644 --- a/.specify/rtm.md +++ b/.specify/rtm.md @@ -209,9 +209,10 @@ | REQ-011 | ≤320px: control overflow-free + tap ≥44px, each abbreviation carries its full word as aria-label | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/GroupingControl.svelte` | `GroupingControl.svelte.spec.ts#exposes each segment full word as an aria-label`, `e2e/zeitstrahl-grouping.spec.ts#the control stays overflow-free and operable at 320px` | Done | | REQ-012 | new grouping/bucket Paraglide keys in de/en/es; no collision with existing timeline*_ keys | #827 | timeline-grouping-modes | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl grouping + bucket keys are present in all locales (#827 REQ-012)`, `messages.spec.ts#de, en, and es have identical key sets` | Done | | REQ-013 | failed timeline fetch → existing localized error via getErrorMessage; grouping has no independent failure mode | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.server.ts` (#779, unchanged) | `zeitstrahl/page.server` error path (#779 — getErrorMessage(extractErrorCode)) | Done | -| REQ-014 | Ereignis event-clustered letter renders as the `.lcard.ev` variant | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterCard.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte` | `LetterCard.svelte.spec.ts#carries the .lcard.ev class in the event variant`, `LetterBucket.svelte.spec.ts#renders its letters as .lcard.ev event cards` | Done | -| REQ-015 | Thema bucket-header chip tinted via rootTagColor `var(--c-tag-*)`, neutral fallback when null/unknown | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/BucketHeaderChip.svelte` | `BucketHeaderChip.svelte.spec.ts#tints the chip with var(--c-tag-{token})`, `#renders a neutral chip with no --c-tag- binding when colour is null`, `#falls back to neutral for an unknown colour token` | Done | +| REQ-014 | Ereignis event-clustered letter renders as the `.lcard.ev` variant, **nested directly under its event pill** with no duplicate title (redesign #847) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterCard.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte`, `frontend/src/lib/timeline/YearBand.svelte` | `LetterCard.svelte.spec.ts#carries the .lcard.ev class in the event variant`, `LetterBucket.svelte.spec.ts#renders its letters as .lcard.ev event cards`, `YearBand.svelte.spec.ts#nests an event cluster under its pill in the same year without repeating the title` | Done | +| REQ-015 | Thema bucket-header chip tinted via rootTagColor `var(--c-tag-*)`, neutral fallback when null/unknown; **label kept in a fixed ink for ≥4.5:1 contrast** (redesign #847) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/BucketHeaderChip.svelte` | `BucketHeaderChip.svelte.spec.ts#tints the chip with var(--c-tag-{token})`, `#renders a neutral chip with no --c-tag- binding when colour is null`, `#falls back to neutral for an unknown colour token`, `#paints the label in a fixed ink colour, never the saturated tag token` | Done | | REQ-016 | header meta-line grouping segment tracks the active mode (date/event/thema keys) | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.svelte` | `zeitstrahl/page.svelte.spec.ts#updates the meta-line grouping label when a mode is chosen` | Done | | REQ-017 | Thema: per-letter TagChip suppressed inside its own bucket; still shown in Datum/Ereignis | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterCard.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte` | `LetterCard.svelte.spec.ts#suppresses the per-letter tag chip when asked`, `#still shows the per-letter tag chip when not suppressed`, `LetterBucket.svelte.spec.ts#suppresses the per-letter tag chip inside its own root-tag bucket` | Done | | REQ-018 | Letters layer off → grouping control disabled (kept in place), mode retained | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/GroupingControl.svelte`, `frontend/src/lib/timeline/timelineGrouping.ts#hasLooseLetters` | `zeitstrahl/page.svelte.spec.ts#disables the grouping control when the Letters layer is off`, `GroupingControl.svelte.spec.ts#retains the active mode while disabled`, `timelineGrouping.spec.ts#hasLooseLetters` | Done | | REQ-019 | Ereignis: letter whose only linking event was filtered off → "Weitere Briefe" (never re-introduced) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts#buildEventLookup`, `frontend/src/lib/timeline/TimelineView.svelte` | `timelineGrouping.spec.ts#drops a letter whose linked event is absent from the lookup into fallback` | Done | +| REQ-020 | Grouped buckets are bound by a colour rail and carry compact cards; a bucket over `BUCKET_DENSE_THRESHOLD` (6) collapses to the month-density `YearLetterStrip` instead of flooding the timeline (redesign #847) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterBucket.svelte`, `frontend/src/lib/timeline/timelineGrouping.ts#isBucketDense`, `frontend/src/lib/timeline/LetterCard.svelte` | `LetterBucket.svelte.spec.ts#collapses an oversized bucket to the density strip instead of flooding cards`, `#binds a tag bucket together with a coloured left rail from its token`, `#renders compact cards for a small bucket`, `LetterCard.svelte.spec.ts#renders the compact variant on a single tighter row` | Done | -- 2.49.1 From 74bf1d864cd73ef516b6677b2d576575b7b68f67 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 14:36:35 +0200 Subject: [PATCH 20/25] docs(timeline): design doc for the grouped-view contained-card layout Records the visual-brainstorm outcome for #827's grouped view: a cluster becomes one contained card (event/tag as header, first 5 letters + show-more), the leftover bin collapses to a count-only drawer, derived/world fixtures stay plain, and REQ-001/003/014/020 are amended. Mockups under .superpowers/brainstorm/ (gitignored). Refs #827 #847 --- ...5-zeitstrahl-grouped-view-layout-design.md | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-15-zeitstrahl-grouped-view-layout-design.md diff --git a/docs/superpowers/specs/2026-06-15-zeitstrahl-grouped-view-layout-design.md b/docs/superpowers/specs/2026-06-15-zeitstrahl-grouped-view-layout-design.md new file mode 100644 index 00000000..b5420d37 --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-zeitstrahl-grouped-view-layout-design.md @@ -0,0 +1,102 @@ +# Zeitstrahl grouped-view layout redesign + +**Date:** 2026-06-15 +**Feature:** #827 (regroup `/zeitstrahl` by Ereignis/Thema) — layout follow-up on PR #847 +**Status:** Approved (brainstorm), pending implementation plan + +> The REQ contract for #827 lives in the Gitea issue body (and the amendment comment of +> 2026-06-15). This document records the **layout/visual design** agreed in the visual +> brainstorm and the REQ deltas it implies. Mockups: `.superpowers/brainstorm/*/content/`. + +## Problem + +The first grouped-view implementation (PR #847) fixed the flood and the duplicate event title, +but two issues remained on review of the live view: + +1. **Weak belonging.** A clustered event's letters dropped below its centered pill as a + full-width block with only a thin left rail. The connection between an event and its letters + read weakly — the eye couldn't tell the letters belonged to the pill above. +2. **Layout inconsistency.** In Datum mode letters alternate left/right of the centered spine + (events/density centered). In grouped mode the letters became full-width, breaking that + rhythm with no clear reason. + +## Decision: a cluster is one contained card + +A clustered event (Ereignis) or root tag (Thema) renders as **one bordered card** whose header +is the event/tag itself and whose body holds that cluster's letters. Belonging becomes +structural (a single container), not positional guesswork. This replaces the full-width block. + +### Ereignis mode, per year band + +1. **Derived life-events** (Geburt/Tod/Heirat, `abgeleitet`) never cluster — they carry no + document links, so they are always **plain axis fixtures, unchanged from Datum mode**. A + **world-band** (`historisch`) is normally letterless and stays a plain band; on the rare + occasion a historical event has linked letters it follows rule 2 (becomes a card). +2. **A curated event (PERSONAL or HISTORICAL) with letters in this band** → one mint-bordered card: + - **Header** = the event's glyph + title + date + `kuratiert` + edit-✎ + count (the pill's + content, laid out as a header bar). This *replaces* the separate floating pill for that + event in this band — killing the duplicate title. + - **Body** = the cluster's letters, **first 5 shown, then a "+ N weitere Briefe anzeigen" + toggle** that expands/collapses the rest. Letters use the compact `LetterCard` variant. +3. **A curated event with no letters in this band** → stays a plain centered pill (no empty card). +4. **A curated event whose letters fall in a different year than its pill** → those letters form a + labeled card in *their* year (header = event name as text, no ✎/pill since the pill lives + elsewhere); the pill stays in its own band. No adjacent duplication. +5. **Leftover letters** (linked to no surviving curated event) → a collapsed neutral, dashed + **"✉ N Briefe ohne Ereignis · anzeigen ›"** drawer. Clicking expands to the same first-5 + + show-more list. No preview letters until opened. + +### Thema mode + +Identical shape. Each card's header is the **tinted root-tag chip** (`● Krieg · 24`, +`BucketHeaderChip`, fixed-ink label per the contrast fix) instead of an event pill; there is no +axis pill for a tag, so every tag cluster is a standalone card. The per-letter `TagChip` stays +suppressed inside its own card (REQ-017). The leftover drawer reads **"Ohne Thema"**. + +### Layout / spine + +- Cluster cards are **centered on the spine** (like events already are), not full-width-flush — + consistent with how grouped units (events) relate to the axis. Individual chronological + letters keep alternating left/right only in **Datum** mode. +- Each card carries a colour left rail: **mint** for an Ereignis cluster, the **tag colour** for + a Thema cluster, **neutral dashed** for the leftover drawer. + +## Components affected + +- `LetterBucket.svelte` — becomes the contained card: header slot (pill-content / tag chip / + drawer label / cross-year text label) + body with the first-5 cap and the show-more toggle. + Drop the `YearLetterStrip` (sparkline) branch from grouped mode. +- `YearBand.svelte` — in Ereignis mode, a same-year curated event renders *as* the card header + (merge pill into the card) instead of pill-then-nested-bucket; derived/world/letterless events + stay plain; cross-year clusters and the leftover drawer render after the axis entries. +- `LetterCard.svelte` — compact variant already exists (PR #847); reused inside cards. +- `BucketHeaderChip.svelte` — reused as the Thema card header (contrast fix already shipped). +- `timelineGrouping.ts` — the first-visible cap (`CLUSTER_PREVIEW = 5`) replaces + `BUCKET_DENSE_THRESHOLD`; helpers unchanged otherwise. +- Possibly a small `ClusterCard`/header sub-component if `LetterBucket` grows too large. + +## REQ deltas (to fold into issue #827) + +- **REQ-001 (amended):** derived life-events, world-bands, and *letterless* curated event pills + render identically across modes; a curated event **that has letters** renders as its cluster + card's header in grouped mode (no longer byte-identical to its Datum pill). Every event keeps + its spine position (year). +- **REQ-003 / REQ-014 (amended):** event-clustered letters live inside a contained card; the + header is the event (same-year) or a text label (cross-year). First 5 shown + show-more. +- **REQ-020 (amended):** grouped clusters are contained colour-railed cards with a first-5 + preview + show-more toggle; the leftover bin is a collapsed count-only drawer. The + month-density `YearLetterStrip` is **no longer used in grouped mode** (still used in Datum + dense years). + +## Out of scope + +- Datum mode (untouched — keeps the alternating-axis zigzag and the >12 sparkline strip). +- Backend / DTO (`linkedEventId` and root-tag fields already shipped; no change). +- New i18n beyond a show-more / drawer label string set. + +## Testing approach + +TDD per component, mirroring PR #847: `LetterBucket` (card header variants, first-5 cap, +show-more expand/collapse, drawer collapsed-by-default, colour rail), `YearBand` (same-year merge += no duplicate title; cross-year keeps a label; derived/world pills unchanged), and the route +spec for the assembled view. Run targeted `--project=client` / `--project=server` specs only. -- 2.49.1 From 5a8bee397020f7f4661dbcc4200a8582fab79d7f Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 14:48:10 +0200 Subject: [PATCH 21/25] feat(timeline): cap grouped clusters at 5 letters with a show-more toggle Replaces the in-bucket month-density sparkline with a first-5 preview + show-more / show-less toggle, the agreed grouped-view pattern. Datum mode keeps the >12 YearLetterStrip. Refs #827 --- frontend/messages/de.json | 2 + frontend/messages/en.json | 2 + frontend/messages/es.json | 2 + frontend/src/lib/timeline/LetterBucket.svelte | 57 ++++++++++++------- .../lib/timeline/LetterBucket.svelte.spec.ts | 57 ++++++++++--------- frontend/src/lib/timeline/timelineGrouping.ts | 13 +---- 6 files changed, 74 insertions(+), 59 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index bc27db1a..84632358 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -1063,6 +1063,8 @@ "timeline_grouping_multitag_hint": "Brief mit mehreren Tags erscheint unter seinem primären Tag.", "timeline_bucket_other_letters": "Weitere Briefe", "timeline_bucket_no_topic": "Ohne Thema", + "timeline_bucket_show_more": "+ {count} weitere Briefe anzeigen", + "timeline_bucket_show_less": "Weniger anzeigen", "timeline_provenance_derived": "abgeleitet", "timeline_provenance_curated": "kuratiert", "timeline_letter_glyph_label": "Brief", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index dd4a9271..0899da7d 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1063,6 +1063,8 @@ "timeline_grouping_multitag_hint": "A letter with several tags appears under its primary tag.", "timeline_bucket_other_letters": "More letters", "timeline_bucket_no_topic": "No topic", + "timeline_bucket_show_more": "+ {count} more letters", + "timeline_bucket_show_less": "Show fewer", "timeline_provenance_derived": "derived", "timeline_provenance_curated": "curated", "timeline_letter_glyph_label": "Letter", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index a1712d41..5d3c6aaa 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1063,6 +1063,8 @@ "timeline_grouping_multitag_hint": "Una carta con varias etiquetas aparece bajo su etiqueta principal.", "timeline_bucket_other_letters": "Más cartas", "timeline_bucket_no_topic": "Sin tema", + "timeline_bucket_show_more": "+ {count} cartas más", + "timeline_bucket_show_less": "Mostrar menos", "timeline_provenance_derived": "derivado", "timeline_provenance_curated": "curado", "timeline_letter_glyph_label": "Carta", diff --git a/frontend/src/lib/timeline/LetterBucket.svelte b/frontend/src/lib/timeline/LetterBucket.svelte index 58b96595..3353e497 100644 --- a/frontend/src/lib/timeline/LetterBucket.svelte +++ b/frontend/src/lib/timeline/LetterBucket.svelte @@ -2,9 +2,8 @@ import * as m from '$lib/paraglide/messages.js'; import LetterCard from './LetterCard.svelte'; import BucketHeaderChip from './BucketHeaderChip.svelte'; -import YearLetterStrip from './YearLetterStrip.svelte'; import { entryKey } from './entryKey'; -import { isBucketDense, tagColorVar, type LetterBucket } from './timelineGrouping'; +import { CLUSTER_PREVIEW, tagColorVar, type LetterBucket } from './timelineGrouping'; /** * One cluster of loose letters, bound together by a coloured left rail so the group reads as a @@ -17,18 +16,21 @@ import { isBucketDense, tagColorVar, type LetterBucket } from './timelineGroupin * header), a mint rail, `.lcard.ev` cards (REQ-003/014). The standalone "Weitere Briefe" / * "Ohne Thema" fallback keeps its label and a neutral rail (REQ-006/007). * - * A bucket larger than the density threshold collapses to the month-density `YearLetterStrip` - * instead of flooding the timeline with every card (#827) — the catch-all buckets are the biggest. + * A cluster shows its first `CLUSTER_PREVIEW` letters, then a show-more toggle reveals the rest + * instead of flooding the timeline with every card (#827 redesign). */ let { bucket, mode, + // `year` is the band's year — accepted for the cross-year label card seam (#827) but no + // longer consumed here now the in-bucket month-density strip is gone (the year frames the + // time from the band heading). Kept in the prop contract for callers/tests. + // eslint-disable-next-line @typescript-eslint/no-unused-vars year = 0, nested = false }: { bucket: LetterBucket; mode: 'event' | 'thema'; year?: number; nested?: boolean } = $props(); const count = $derived(bucket.letters.length); -const dense = $derived(isBucketDense(count)); const fallbackLabel = $derived( mode === 'event' ? m.timeline_bucket_other_letters() : m.timeline_bucket_no_topic() ); @@ -38,6 +40,12 @@ const railColor = $derived(bucket.kind === 'tag' ? tagColorVar(bucket.color) : n const railStyle = $derived(railColor ? `border-left-color: ${railColor}` : ''); const isEventCluster = $derived(nested || bucket.kind === 'event'); const cardVariant = $derived(mode === 'event' && isEventCluster ? 'event' : 'plain'); + +// First-5 preview + show-more (#827 redesign): a large cluster stays readable instead of +// dumping every card into the timeline. +let expanded = $state(false); +const visible = $derived(expanded ? bucket.letters : bucket.letters.slice(0, CLUSTER_PREVIEW)); +const hiddenCount = $derived(bucket.letters.length - CLUSTER_PREVIEW);
    {/if} - {#if dense} - - - {:else} -
      - {#each bucket.letters as letter (entryKey(letter))} -
    • - -
    • - {/each} -
    +
      + {#each visible as letter (entryKey(letter))} +
    • + +
    • + {/each} +
    + {#if hiddenCount > 0} + {/if}
    diff --git a/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts b/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts index 22b6b137..6fe2b996 100644 --- a/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts +++ b/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; +import { tick } from 'svelte'; import * as m from '$lib/paraglide/messages.js'; import LetterBucket from './LetterBucket.svelte'; import { makeEntry } from './test-factories'; @@ -78,47 +79,49 @@ const manyLetters = (n: number) => makeEntry({ documentId: `d${i}`, eventDate: `1916-0${(i % 9) + 1}-01` }) ); -describe('LetterBucket — density + containment (#827)', () => { - it('collapses an oversized bucket to the density strip instead of flooding cards', () => { +describe('LetterBucket — preview cap + show-more (#827 redesign)', () => { + it('shows only the first 5 letters with a show-more toggle when the cluster is larger', () => { const bucket: Bucket = { key: 'tag:t1', kind: 'tag', - title: 'Sonstiges', - color: null, - letters: manyLetters(10) + title: 'Krieg', + color: 'sienna', + letters: manyLetters(8) }; render(LetterBucket, { bucket, mode: 'thema', year: 1916 }); - expect(document.querySelector('[data-testid="strip-expand"]')).not.toBeNull(); - // not ten individual cards dumped into the timeline - expect(document.querySelectorAll('a.lcard')).toHaveLength(0); + expect(document.querySelectorAll('a.lcard')).toHaveLength(5); + expect(document.querySelector('[data-testid="bucket-show-more"]')).not.toBeNull(); + expect(document.querySelector('[data-testid="strip-expand"]')).toBeNull(); // sparkline gone }); - it('renders compact cards for a small bucket (no strip)', () => { + it('expands to all letters and collapses back on toggle', async () => { + const bucket: Bucket = { + key: 'tag:t1', + kind: 'tag', + title: 'Krieg', + color: 'sienna', + letters: manyLetters(8) + }; + render(LetterBucket, { bucket, mode: 'thema', year: 1916 }); + (document.querySelector('[data-testid="bucket-show-more"]') as HTMLButtonElement).click(); + await tick(); + expect(document.querySelectorAll('a.lcard')).toHaveLength(8); + (document.querySelector('[data-testid="bucket-show-more"]') as HTMLButtonElement).click(); + await tick(); + expect(document.querySelectorAll('a.lcard')).toHaveLength(5); + }); + + it('shows all letters and no toggle for a small cluster (<= 5)', () => { const bucket: Bucket = { key: 'tag:t1', kind: 'tag', title: 'Tod', color: null, - letters: manyLetters(2) + letters: manyLetters(3) }; render(LetterBucket, { bucket, mode: 'thema', year: 1916 }); - expect(document.querySelector('[data-testid="strip-expand"]')).toBeNull(); - expect(document.querySelectorAll('a.lcard.compact')).toHaveLength(2); - }); - - it('omits the header when nested — the event pill above is the header', () => { - const bucket: Bucket = { - key: 'event:e1', - kind: 'event', - title: 'Ein gewaltiger Stadtbrand', - color: null, - letters: manyLetters(1) - }; - render(LetterBucket, { bucket, mode: 'event', nested: true, year: 1916 }); - expect(document.querySelector('[data-testid="bucket-count"]')).toBeNull(); - expect(document.body.textContent).not.toContain('Ein gewaltiger Stadtbrand'); - // still the event-letter variant, just headerless under its pill - expect(document.querySelector('a.lcard.ev')).not.toBeNull(); + expect(document.querySelectorAll('a.lcard')).toHaveLength(3); + expect(document.querySelector('[data-testid="bucket-show-more"]')).toBeNull(); }); it('binds a tag bucket together with a coloured left rail from its token', () => { diff --git a/frontend/src/lib/timeline/timelineGrouping.ts b/frontend/src/lib/timeline/timelineGrouping.ts index c2d745c2..6b487213 100644 --- a/frontend/src/lib/timeline/timelineGrouping.ts +++ b/frontend/src/lib/timeline/timelineGrouping.ts @@ -14,17 +14,8 @@ export type GroupingMode = 'date' | 'event' | 'thema'; /** The default mode — chronological, as #779 shipped. */ export const DEFAULT_GROUPING: GroupingMode = 'date'; -/** - * A bucket larger than this collapses to a month-density strip instead of flooding the - * timeline with individual cards (#827) — the catch-all "Weitere Briefe"/"Ohne Thema" - * buckets are always the biggest, so without this they swamp the grouped view. Lower than - * Datum mode's `DENSE_THRESHOLD` (12) because a bucket is a narrower context than a year. - */ -export const BUCKET_DENSE_THRESHOLD = 6; - -export function isBucketDense(letterCount: number): boolean { - return letterCount > BUCKET_DENSE_THRESHOLD; -} +/** Letters shown before a cluster needs a "show more" toggle (#827 redesign). */ +export const CLUSTER_PREVIEW = 5; /** The `--c-tag-*` colour-name tokens (sage/sienna/…); shared by the chip and the rail. */ const TAG_COLOR_TOKENS = new Set([ -- 2.49.1 From bea9acfe63810f2a7f90a7b0639a4c2153f2a090 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 14:50:35 +0200 Subject: [PATCH 22/25] feat(timeline): collapse the leftover Weitere-Briefe/Ohne-Thema bin to a drawer The catch-all bucket renders count-only by default behind a reveal control, then expands to the first-5 + show-more body. Keeps the junk drawer quiet instead of flooding the timeline. Refs #827 --- frontend/src/lib/timeline/LetterBucket.svelte | 59 +++++++++++++------ .../lib/timeline/LetterBucket.svelte.spec.ts | 24 ++++++++ 2 files changed, 65 insertions(+), 18 deletions(-) diff --git a/frontend/src/lib/timeline/LetterBucket.svelte b/frontend/src/lib/timeline/LetterBucket.svelte index 3353e497..986e4790 100644 --- a/frontend/src/lib/timeline/LetterBucket.svelte +++ b/frontend/src/lib/timeline/LetterBucket.svelte @@ -46,12 +46,21 @@ const cardVariant = $derived(mode === 'event' && isEventCluster ? 'event' : 'pla let expanded = $state(false); const visible = $derived(expanded ? bucket.letters : bucket.letters.slice(0, CLUSTER_PREVIEW)); const hiddenCount = $derived(bucket.letters.length - CLUSTER_PREVIEW); + +// The catch-all "Weitere Briefe" / "Ohne Thema" bin is a junk drawer: render it count-only +// behind a reveal control so it never floods the timeline; every other cluster starts open +// (#827 redesign). The view re-creates a bucket per `{#each}` key, so the initial capture is +// the right lifetime — `revealed` belongs to this bucket instance. +const isDrawer = $derived(bucket.kind === 'fallback'); +// svelte-ignore state_referenced_locally +let revealed = $state(bucket.kind !== 'fallback');
    {/if} -
      - {#each visible as letter (entryKey(letter))} -
    • - -
    • - {/each} -
    - {#if hiddenCount > 0} + {#if !revealed} + {:else} +
      + {#each visible as letter (entryKey(letter))} +
    • + +
    • + {/each} +
    + {#if hiddenCount > 0} + + {/if} {/if}
    diff --git a/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts b/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts index 6fe2b996..79b1d63a 100644 --- a/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts +++ b/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts @@ -137,3 +137,27 @@ describe('LetterBucket — preview cap + show-more (#827 redesign)', () => { expect(section.getAttribute('style') ?? '').toContain('var(--c-tag-sienna)'); }); }); + +describe('LetterBucket — leftover drawer (#827 redesign)', () => { + const fb = (n: number): Bucket => ({ + key: '__fallback__', + kind: 'fallback', + color: null, + letters: Array.from({ length: n }, (_, i) => + makeEntry({ documentId: `f${i}`, eventDate: `1916-01-0${(i % 9) + 1}` }) + ) + }); + it('renders collapsed — count + reveal, no letter cards — until opened', () => { + render(LetterBucket, { bucket: fb(20), mode: 'event', year: 1916 }); + expect(document.querySelector('a.lcard')).toBeNull(); + expect(document.body.textContent).toContain(m.timeline_bucket_other_letters()); + expect(document.querySelector('[data-testid="bucket-reveal"]')).not.toBeNull(); + }); + it('reveals the first 5 letters when opened', async () => { + render(LetterBucket, { bucket: fb(20), mode: 'event', year: 1916 }); + (document.querySelector('[data-testid="bucket-reveal"]') as HTMLButtonElement).click(); + await tick(); + expect(document.querySelectorAll('a.lcard')).toHaveLength(5); + expect(document.querySelector('[data-testid="bucket-show-more"]')).not.toBeNull(); + }); +}); -- 2.49.1 From e10021376068a42ba6dc778e883cb50f0d33b7ae Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 14:52:28 +0200 Subject: [PATCH 23/25] feat(timeline): make a grouped cluster one contained card Wraps each cluster in a bordered, rounded surface card (keeping the colour rail) so the header and its letters read as a single unit. Refs #827 --- frontend/src/lib/timeline/LetterBucket.svelte | 76 ++++++++++--------- .../lib/timeline/LetterBucket.svelte.spec.ts | 17 +++++ 2 files changed, 58 insertions(+), 35 deletions(-) diff --git a/frontend/src/lib/timeline/LetterBucket.svelte b/frontend/src/lib/timeline/LetterBucket.svelte index 986e4790..12fb85a9 100644 --- a/frontend/src/lib/timeline/LetterBucket.svelte +++ b/frontend/src/lib/timeline/LetterBucket.svelte @@ -57,16 +57,20 @@ let revealed = $state(bucket.kind !== 'fallback');
    {#if !nested} -
    +
    {#if mode === 'thema' && bucket.kind === 'tag'} {:else if mode === 'event' && bucket.kind === 'event'} @@ -81,42 +85,44 @@ let revealed = $state(bucket.kind !== 'fallback');
    {/if} - {#if !revealed} - - {:else} -
      - {#each visible as letter (entryKey(letter))} -
    • - -
    • - {/each} -
    - {#if hiddenCount > 0} +
    + {#if !revealed} + {:else} +
      + {#each visible as letter (entryKey(letter))} +
    • + +
    • + {/each} +
    + {#if hiddenCount > 0} + + {/if} {/if} - {/if} +
    diff --git a/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts b/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts index 79b1d63a..854460f5 100644 --- a/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts +++ b/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts @@ -161,3 +161,20 @@ describe('LetterBucket — leftover drawer (#827 redesign)', () => { expect(document.querySelector('[data-testid="bucket-show-more"]')).not.toBeNull(); }); }); + +describe('LetterBucket — card chrome (#827 redesign)', () => { + it('renders the cluster as a contained card (bordered, rounded, surface)', () => { + const bucket: Bucket = { + key: 'tag:t1', + kind: 'tag', + title: 'Krieg', + color: 'sienna', + letters: [makeEntry({ documentId: 'a' })] + }; + render(LetterBucket, { bucket, mode: 'thema', year: 1916 }); + const card = document.querySelector('[data-testid="letter-bucket"]') as HTMLElement; + expect(card.className).toMatch(/\brounded\b|rounded-/); + expect(card.className).toContain('border'); + expect(card.className).toContain('bg-surface'); + }); +}); -- 2.49.1 From 70794616d2b87c726ac2eb8148205d7ef81940fd Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 14:56:57 +0200 Subject: [PATCH 24/25] feat(timeline): render a same-year curated event as its cluster card header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A curated event with letters in its own band now becomes the contained card header (glyph, title, date, provenance, edit pencil) instead of a separate floating pill — the title reads once. Derived life-events, world-bands, and letterless event pills are unchanged (REQ-001 amended for curated-with-letters; the identity fixture now links its letter to the curated event so the letterless world band stays a band). Refs #827 --- frontend/src/lib/timeline/LetterBucket.svelte | 113 ++++++++++++++---- .../lib/timeline/LetterBucket.svelte.spec.ts | 52 ++++++++ frontend/src/lib/timeline/YearBand.svelte | 41 ++++--- .../src/lib/timeline/YearBand.svelte.spec.ts | 11 +- ...ouping-event-layer-identity.svelte.spec.ts | 7 +- 5 files changed, 181 insertions(+), 43 deletions(-) diff --git a/frontend/src/lib/timeline/LetterBucket.svelte b/frontend/src/lib/timeline/LetterBucket.svelte index 12fb85a9..0c86c43c 100644 --- a/frontend/src/lib/timeline/LetterBucket.svelte +++ b/frontend/src/lib/timeline/LetterBucket.svelte @@ -3,18 +3,24 @@ import * as m from '$lib/paraglide/messages.js'; import LetterCard from './LetterCard.svelte'; import BucketHeaderChip from './BucketHeaderChip.svelte'; import { entryKey } from './entryKey'; +import { getAccentConfig } from './eventCardConfig'; +import { timelineDateLabel } from './dateLabel'; import { CLUSTER_PREVIEW, tagColorVar, type LetterBucket } from './timelineGrouping'; +import type { components } from '$lib/generated/api'; + +type TimelineEntryDTO = components['schemas']['TimelineEntryDTO']; /** * One cluster of loose letters, bound together by a coloured left rail so the group reads as a - * unit (#827). The axis-fixed event/world-band layers are rendered elsewhere — this is only the + * unit (#827). The axis-fixed world-band layer is rendered elsewhere — this is only the * loose-letter bundling. * * - Thema: a tinted root-tag header chip + a rail in the tag colour, over compact cards whose own * tag chip is suppressed (REQ-004/015/017). - * - Ereignis: rendered `nested` directly beneath its event pill — no header (the pill is the - * header), a mint rail, `.lcard.ev` cards (REQ-003/014). The standalone "Weitere Briefe" / - * "Ohne Thema" fallback keeps its label and a neutral rail (REQ-006/007). + * - Ereignis: a same-year curated `event` becomes the card header (glyph, title, date, + * provenance, edit pencil) so its title reads once — no separate floating pill (#827 redesign, + * REQ-001/014). A cross-year cluster keeps a plain text header. The standalone "Weitere Briefe" + * / "Ohne Thema" fallback keeps its label and a neutral dashed rail (REQ-006/007). * * A cluster shows its first `CLUSTER_PREVIEW` letters, then a show-more toggle reveals the rest * instead of flooding the timeline with every card (#827 redesign). @@ -27,13 +33,36 @@ let { // time from the band heading). Kept in the prop contract for callers/tests. // eslint-disable-next-line @typescript-eslint/no-unused-vars year = 0, - nested = false -}: { bucket: LetterBucket; mode: 'event' | 'thema'; year?: number; nested?: boolean } = $props(); + nested = false, + event = undefined, + canWrite = false +}: { + bucket: LetterBucket; + mode: 'event' | 'thema'; + year?: number; + nested?: boolean; + /** The same-year curated event whose letters this card holds — renders as the header. */ + event?: TimelineEntryDTO; + canWrite?: boolean; +} = $props(); const count = $derived(bucket.letters.length); const fallbackLabel = $derived( mode === 'event' ? m.timeline_bucket_other_letters() : m.timeline_bucket_no_topic() ); + +// Event-as-header (#827 redesign): a same-year curated event renders as this card's header, +// mirroring EventPill — glyph + title + date · provenance + an edit pencil for a curator. The +// title is never repeated as a separate floating pill. +const accent = $derived(event ? getAccentConfig(event) : null); +const eventDateLabel = $derived( + event ? timelineDateLabel(event.eventDate, event.precision, event.eventDateEnd) : null +); +const provenance = $derived( + event?.derived ? m.timeline_provenance_derived() : m.timeline_provenance_curated() +); +const eventSubtitle = $derived(eventDateLabel ? `${eventDateLabel} · ${provenance}` : provenance); +const canEdit = $derived(canWrite && !!event && !event.derived && event.eventId != null); // The left rail binds the cluster: the tag colour in Thema, mint for an Ereignis cluster, // neutral for the fallback (and for a colourless/unknown tag token). const railColor = $derived(bucket.kind === 'tag' ? tagColorVar(bucket.color) : null); @@ -65,24 +94,62 @@ let revealed = $state(bucket.kind !== 'fallback'); data-bucket-kind={bucket.kind} > {#if !nested} -
    - {#if mode === 'thema' && bucket.kind === 'tag'} - - {:else if mode === 'event' && bucket.kind === 'event'} - - - {bucket.title} + {#if event && accent} + +
    + + + {accent.label} - {:else} - {fallbackLabel} - {/if} - · {count} -
    + + {event.title} + + {eventSubtitle} · {count} + + + {#if canEdit} +
    + + {m.btn_edit()} + + {/if} +
    + {:else} +
    + {#if mode === 'thema' && bucket.kind === 'tag'} + + {:else if mode === 'event' && bucket.kind === 'event'} + + + {bucket.title} + + {:else} + {fallbackLabel} + {/if} + · {count} +
    + {/if} {/if}
    diff --git a/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts b/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts index 854460f5..326a4683 100644 --- a/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts +++ b/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts @@ -178,3 +178,55 @@ describe('LetterBucket — card chrome (#827 redesign)', () => { expect(card.className).toContain('bg-surface'); }); }); + +describe('LetterBucket — event-as-header (#827 redesign)', () => { + it('renders the curated event as the card header when given an `event` (no separate pill)', () => { + const event = makeEntry({ + kind: 'EVENT', + type: 'PERSONAL', + derived: false, + eventId: 'e1', + title: 'Ein gewaltiger Stadtbrand', + eventDate: '1916-07-06', + senderName: '', + receiverName: '', + documentId: undefined + }); + const bucket: Bucket = { + key: 'event:e1', + kind: 'event', + title: 'Ein gewaltiger Stadtbrand', + color: null, + letters: [makeEntry({ documentId: 'a', linkedEventId: 'e1' })] + }; + render(LetterBucket, { bucket, mode: 'event', year: 1916, event, canWrite: true }); + const header = document.querySelector('[data-testid="bucket-event-header"]') as HTMLElement; + expect(header.textContent).toContain('Ein gewaltiger Stadtbrand'); + expect(header.textContent).toContain(m.timeline_provenance_curated()); + expect(document.querySelector('[data-testid="event-edit"]')?.getAttribute('href')).toBe( + '/zeitstrahl/events/e1/edit' + ); + }); + + it('shows no edit affordance in the header when canWrite is false', () => { + const event = makeEntry({ + kind: 'EVENT', + type: 'PERSONAL', + derived: false, + eventId: 'e1', + title: 'X', + senderName: '', + receiverName: '', + documentId: undefined + }); + const bucket: Bucket = { + key: 'event:e1', + kind: 'event', + title: 'X', + color: null, + letters: [makeEntry({ documentId: 'a' })] + }; + render(LetterBucket, { bucket, mode: 'event', year: 1916, event, canWrite: false }); + expect(document.querySelector('[data-testid="event-edit"]')).toBeNull(); + }); +}); diff --git a/frontend/src/lib/timeline/YearBand.svelte b/frontend/src/lib/timeline/YearBand.svelte index 17f704a4..f643885b 100644 --- a/frontend/src/lib/timeline/YearBand.svelte +++ b/frontend/src/lib/timeline/YearBand.svelte @@ -41,6 +41,7 @@ let { type Row = | { t: 'event'; entry: TimelineEntryDTO } + | { t: 'eventcard'; entry: TimelineEntryDTO; bucket: LetterBucketModel } | { t: 'letter'; entry: TimelineEntryDTO; side: 'left' | 'right' } | { t: 'strip' } | { t: 'bucket'; bucket: LetterBucketModel; nested: boolean }; @@ -52,26 +53,30 @@ const bucketMode = $derived(groupingMode === 'thema' ? 'thema' : 'event'); const rows = $derived.by(() => { const out: Row[] = []; - // Ereignis: events stay on the axis (REQ-001); each curated event's letters nest directly - // beneath its pill — the pill IS the header, so the title is never repeated. A cluster whose - // pill lives in another year band (or was filtered out) keeps its own header here, and the - // unlinked letters fall to the single "Weitere Briefe" bucket (REQ-003/006/019). + // Ereignis: events stay on the axis (REQ-001). A curated event WITH letters in this band + // becomes the contained card's header (no separate pill — its title reads once, #827 + // redesign); a letterless/derived/world event stays a plain pill/band. A cluster whose event + // lives in another year band (or was filtered out) renders as a text-header card here, and + // the unlinked letters fall to the single "Weitere Briefe" drawer (REQ-003/006/019). if (groupingMode === 'event') { const buckets = bucketLetters(letters, 'event', eventLookup); - const hasPill = (bucketKey: string) => - year.entries.some((e) => e.kind === 'EVENT' && `event:${e.eventId}` === bucketKey); - // Each pill renders, then its same-year cluster nests directly beneath it (no header). + const sameYearBucket = (id: string | undefined) => + id ? buckets.find((b) => b.kind === 'event' && b.key === `event:${id}`) : undefined; for (const entry of year.entries) { if (entry.kind !== 'EVENT') continue; - out.push({ t: 'event', entry }); - const bucket = entry.eventId - ? buckets.find((b) => b.kind === 'event' && b.key === `event:${entry.eventId}`) - : undefined; - if (bucket) out.push({ t: 'bucket', bucket, nested: true }); + const bucket = sameYearBucket(entry.eventId); + // A curated event with same-year letters becomes the card header (card replaces pill); + // otherwise it stays a plain pill/world-band. + if (bucket) out.push({ t: 'eventcard', entry, bucket }); + else out.push({ t: 'event', entry }); } - // Clusters whose pill is in another band keep their header; then the fallback, last. + // Cross-year clusters (no matching event entry in this band) and the fallback drawer + // render after the axis entries, with their own text header. for (const bucket of buckets) { - if (bucket.kind === 'fallback' || !hasPill(bucket.key)) { + if ( + bucket.kind === 'fallback' || + !year.entries.some((e) => e.kind === 'EVENT' && `event:${e.eventId}` === bucket.key) + ) { out.push({ t: 'bucket', bucket, nested: false }); } } @@ -131,6 +136,14 @@ function rowKey(row: Row): string { {:else} {/if} + {:else if row.t === 'eventcard'} + {:else if row.t === 'letter'}
    diff --git a/frontend/src/lib/timeline/YearBand.svelte.spec.ts b/frontend/src/lib/timeline/YearBand.svelte.spec.ts index 9f2a8ece..c8fdae0f 100644 --- a/frontend/src/lib/timeline/YearBand.svelte.spec.ts +++ b/frontend/src/lib/timeline/YearBand.svelte.spec.ts @@ -222,7 +222,7 @@ describe('YearBand — grouping modes (#827)', () => { expect(chip?.textContent).toContain('Krieg'); }); - it('nests an event cluster under its pill in the same year without repeating the title (#827)', () => { + it('renders a same-year curated event as one card header, with no separate pill and no duplicate title (#827)', () => { const pill = makeEntry({ kind: 'EVENT', type: 'PERSONAL', @@ -238,14 +238,15 @@ describe('YearBand — grouping modes (#827)', () => { render(YearBand, { year: makeYear(1916, [pill, letter]), groupingMode: 'event', - eventLookup: new Map([['e1', 'Ein gewaltiger Stadtbrand']]) + eventLookup: new Map([['e1', 'Ein gewaltiger Stadtbrand']]), + canWrite: true }); - // the title appears exactly once — on the axis pill, NOT also as a bucket header + // the title appears exactly once — in the card header, not also as a separate pill const occurrences = (document.body.textContent ?? '').split('Ein gewaltiger Stadtbrand').length - 1; expect(occurrences).toBe(1); - // the letter is still clustered (nested under the pill) as the event-letter card - expect(document.querySelector('[data-testid="letter-bucket"]')).not.toBeNull(); + // the event renders as the card header, with its letter clustered inside + expect(document.querySelector('[data-testid="bucket-event-header"]')).not.toBeNull(); expect(document.querySelector('a.lcard.ev')).not.toBeNull(); }); diff --git a/frontend/src/lib/timeline/grouping-event-layer-identity.svelte.spec.ts b/frontend/src/lib/timeline/grouping-event-layer-identity.svelte.spec.ts index a430ebaa..7f2a724d 100644 --- a/frontend/src/lib/timeline/grouping-event-layer-identity.svelte.spec.ts +++ b/frontend/src/lib/timeline/grouping-event-layer-identity.svelte.spec.ts @@ -46,13 +46,18 @@ function eventLayerSignature(): string { }); } +// Brief A links to the curated event p1 (Hochzeit), not the world band — so the world band +// stays letterless and renders as a plain band in every mode (REQ-001). Under the #827 redesign +// a curated event WITH letters becomes its cluster card's header, so the signature tracks the +// stable layer: the letterless world band's marker count and the two titles, which all survive +// regardless of whether Hochzeit renders as a pill (Datum) or a card header (grouped). const mixed = () => makeTimelineDTO({ years: [ makeYear(1915, [ worldBand('Erster Weltkrieg'), eventPill('Hochzeit'), - makeEntry({ documentId: 'a', title: 'Brief A', linkedEventId: 'h1' }), + makeEntry({ documentId: 'a', title: 'Brief A', linkedEventId: 'p1' }), makeEntry({ documentId: 'b', title: 'Brief B', -- 2.49.1 From be4bf8edc0606cb9616e7aa8212f694f616819c4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 14:59:26 +0200 Subject: [PATCH 25/25] docs(rtm): trace the grouped-view contained-card layout (#827) Refs #827 --- .specify/rtm.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.specify/rtm.md b/.specify/rtm.md index bc7e68a5..5dc41979 100644 --- a/.specify/rtm.md +++ b/.specify/rtm.md @@ -209,10 +209,10 @@ | REQ-011 | ≤320px: control overflow-free + tap ≥44px, each abbreviation carries its full word as aria-label | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/GroupingControl.svelte` | `GroupingControl.svelte.spec.ts#exposes each segment full word as an aria-label`, `e2e/zeitstrahl-grouping.spec.ts#the control stays overflow-free and operable at 320px` | Done | | REQ-012 | new grouping/bucket Paraglide keys in de/en/es; no collision with existing timeline*_ keys | #827 | timeline-grouping-modes | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl grouping + bucket keys are present in all locales (#827 REQ-012)`, `messages.spec.ts#de, en, and es have identical key sets` | Done | | REQ-013 | failed timeline fetch → existing localized error via getErrorMessage; grouping has no independent failure mode | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.server.ts` (#779, unchanged) | `zeitstrahl/page.server` error path (#779 — getErrorMessage(extractErrorCode)) | Done | -| REQ-014 | Ereignis event-clustered letter renders as the `.lcard.ev` variant, **nested directly under its event pill** with no duplicate title (redesign #847) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterCard.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte`, `frontend/src/lib/timeline/YearBand.svelte` | `LetterCard.svelte.spec.ts#carries the .lcard.ev class in the event variant`, `LetterBucket.svelte.spec.ts#renders its letters as .lcard.ev event cards`, `YearBand.svelte.spec.ts#nests an event cluster under its pill in the same year without repeating the title` | Done | +| REQ-014 | Ereignis event-clustered letters live inside a **contained card whose header is the same-year curated event** (glyph, title, date, provenance, edit pencil) — the title reads once, no separate floating pill; letters render as the compact `.lcard.ev` variant, first 5 + show-more (redesign #847 → #827 grouped-card layout) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterCard.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte`, `frontend/src/lib/timeline/YearBand.svelte` | `LetterCard.svelte.spec.ts#carries the .lcard.ev class in the event variant`, `LetterBucket.svelte.spec.ts#renders the curated event as the card header when given an `event` (no separate pill)`, `LetterBucket.svelte.spec.ts#shows no edit affordance in the header when canWrite is false`, `YearBand.svelte.spec.ts#renders a same-year curated event as one card header, with no separate pill and no duplicate title` | Done | | REQ-015 | Thema bucket-header chip tinted via rootTagColor `var(--c-tag-*)`, neutral fallback when null/unknown; **label kept in a fixed ink for ≥4.5:1 contrast** (redesign #847) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/BucketHeaderChip.svelte` | `BucketHeaderChip.svelte.spec.ts#tints the chip with var(--c-tag-{token})`, `#renders a neutral chip with no --c-tag- binding when colour is null`, `#falls back to neutral for an unknown colour token`, `#paints the label in a fixed ink colour, never the saturated tag token` | Done | | REQ-016 | header meta-line grouping segment tracks the active mode (date/event/thema keys) | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.svelte` | `zeitstrahl/page.svelte.spec.ts#updates the meta-line grouping label when a mode is chosen` | Done | | REQ-017 | Thema: per-letter TagChip suppressed inside its own bucket; still shown in Datum/Ereignis | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterCard.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte` | `LetterCard.svelte.spec.ts#suppresses the per-letter tag chip when asked`, `#still shows the per-letter tag chip when not suppressed`, `LetterBucket.svelte.spec.ts#suppresses the per-letter tag chip inside its own root-tag bucket` | Done | | REQ-018 | Letters layer off → grouping control disabled (kept in place), mode retained | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/GroupingControl.svelte`, `frontend/src/lib/timeline/timelineGrouping.ts#hasLooseLetters` | `zeitstrahl/page.svelte.spec.ts#disables the grouping control when the Letters layer is off`, `GroupingControl.svelte.spec.ts#retains the active mode while disabled`, `timelineGrouping.spec.ts#hasLooseLetters` | Done | | REQ-019 | Ereignis: letter whose only linking event was filtered off → "Weitere Briefe" (never re-introduced) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts#buildEventLookup`, `frontend/src/lib/timeline/TimelineView.svelte` | `timelineGrouping.spec.ts#drops a letter whose linked event is absent from the lookup into fallback` | Done | -| REQ-020 | Grouped buckets are bound by a colour rail and carry compact cards; a bucket over `BUCKET_DENSE_THRESHOLD` (6) collapses to the month-density `YearLetterStrip` instead of flooding the timeline (redesign #847) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterBucket.svelte`, `frontend/src/lib/timeline/timelineGrouping.ts#isBucketDense`, `frontend/src/lib/timeline/LetterCard.svelte` | `LetterBucket.svelte.spec.ts#collapses an oversized bucket to the density strip instead of flooding cards`, `#binds a tag bucket together with a coloured left rail from its token`, `#renders compact cards for a small bucket`, `LetterCard.svelte.spec.ts#renders the compact variant on a single tighter row` | Done | +| REQ-020 | Grouped clusters are **contained colour-railed cards** (bordered, rounded, surface) carrying compact cards; a cluster shows the first `CLUSTER_PREVIEW` (5) letters behind a show-more toggle, and the leftover bin is a **collapsed count-only drawer** revealed on demand — the month-density `YearLetterStrip` is no longer used in grouped mode (still used in Datum dense years) (redesign #847 → #827 grouped-card layout) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterBucket.svelte`, `frontend/src/lib/timeline/timelineGrouping.ts#CLUSTER_PREVIEW`, `frontend/src/lib/timeline/LetterCard.svelte` | `LetterBucket.svelte.spec.ts#renders the cluster as a contained card (bordered, rounded, surface)`, `#binds a tag bucket together with a coloured left rail from its token`, `#shows only the first 5 letters with a show-more toggle when the cluster is larger`, `#expands to all letters and collapses back on toggle`, `#renders collapsed — count + reveal, no letter cards — until opened`, `#reveals the first 5 letters when opened`, `LetterCard.svelte.spec.ts#renders the compact variant on a single tighter row` | Done | -- 2.49.1