From dc9d1d52b3e354ededfd1a21cb9ce3acaf498ec2 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 14 Jun 2026 20:17:02 +0200 Subject: [PATCH] feat(timeline): add client-side layer-filter helpers Pure helpers for the /zeitstrahl layer filter: isDefaultState and hiddenLayerCount drive the "Filter (N active)" trigger, and filterTimeline derives a client-side view that hides personal/historical/letter layers and drops year bands left empty. Letters ride the Letters layer, HISTORICAL events the Historical layer, and curated PERSONAL plus derived life-events the Personal layer. Refs #780 Co-Authored-By: Claude Opus 4.8 --- .../src/lib/timeline/timelineFilter.spec.ts | 151 ++++++++++++++++++ frontend/src/lib/timeline/timelineFilter.ts | 63 ++++++++ 2 files changed, 214 insertions(+) create mode 100644 frontend/src/lib/timeline/timelineFilter.spec.ts create mode 100644 frontend/src/lib/timeline/timelineFilter.ts diff --git a/frontend/src/lib/timeline/timelineFilter.spec.ts b/frontend/src/lib/timeline/timelineFilter.spec.ts new file mode 100644 index 00000000..a5827238 --- /dev/null +++ b/frontend/src/lib/timeline/timelineFilter.spec.ts @@ -0,0 +1,151 @@ +import { describe, it, expect } from 'vitest'; +import { + isDefaultState, + hiddenLayerCount, + filterTimeline, + ALL_LAYERS_ON, + type TimelineLayerFilters +} from './timelineFilter'; +import { makeEntry, makeYear, makeTimelineDTO } from './test-factories'; + +// Entry factories pinned to the three layers the filter discriminates (#780). +const letter = (overrides = {}) => makeEntry({ kind: 'LETTER', ...overrides }); + +const curatedPersonal = (overrides = {}) => + makeEntry({ + kind: 'EVENT', + type: 'PERSONAL', + derived: false, + documentId: undefined, + title: 'Umzug nach Berlin', + senderName: '', + receiverName: '', + ...overrides + }); + +// Derived life-events carry type=PERSONAL (issue #776 REQ-009) — they belong to +// the Personal layer, not a fourth one. +const derivedLifeEvent = (overrides = {}) => + makeEntry({ + kind: 'EVENT', + type: 'PERSONAL', + derived: true, + derivedType: 'BIRTH', + documentId: undefined, + title: 'Geburt', + senderName: '', + receiverName: '', + ...overrides + }); + +const historical = (overrides = {}) => + makeEntry({ + kind: 'EVENT', + type: 'HISTORICAL', + derived: false, + documentId: undefined, + title: 'Erster Weltkrieg', + senderName: '', + receiverName: '', + ...overrides + }); + +const off = (overrides: Partial): TimelineLayerFilters => ({ + ...ALL_LAYERS_ON, + ...overrides +}); + +describe('isDefaultState (REQ-007)', () => { + it('is true when all three layers are on', () => { + expect(isDefaultState(ALL_LAYERS_ON)).toBe(true); + }); + + it('is false when any single layer is off', () => { + expect(isDefaultState(off({ personalOn: false }))).toBe(false); + expect(isDefaultState(off({ historicalOn: false }))).toBe(false); + expect(isDefaultState(off({ lettersOn: false }))).toBe(false); + }); +}); + +describe('hiddenLayerCount (REQ-007)', () => { + it('is 0 in the default all-on state', () => { + expect(hiddenLayerCount(ALL_LAYERS_ON)).toBe(0); + }); + + it('counts each layer that is off', () => { + expect(hiddenLayerCount(off({ lettersOn: false }))).toBe(1); + expect(hiddenLayerCount(off({ personalOn: false, historicalOn: false }))).toBe(2); + expect(hiddenLayerCount({ personalOn: false, historicalOn: false, lettersOn: false })).toBe(3); + }); +}); + +describe('filterTimeline', () => { + it('returns every entry unchanged in the default all-on state', () => { + const dto = makeTimelineDTO({ + years: [makeYear(1915, [letter(), historical(), curatedPersonal()])], + undated: [letter({ documentId: 'u1' })] + }); + const result = filterTimeline(dto, ALL_LAYERS_ON); + expect(result.years[0].entries).toHaveLength(3); + expect(result.undated).toHaveLength(1); + }); + + it('hides LETTER entries when lettersOn is false, keeping events (REQ-005)', () => { + const dto = makeTimelineDTO({ + years: [makeYear(1915, [letter(), historical(), curatedPersonal()])], + undated: [letter({ documentId: 'u1' })] + }); + const result = filterTimeline(dto, off({ lettersOn: false })); + expect(result.years[0].entries.every((e) => e.kind !== 'LETTER')).toBe(true); + expect(result.years[0].entries).toHaveLength(2); + expect(result.undated).toHaveLength(0); + }); + + it('hides HISTORICAL events when historicalOn is false, keeping personal + letters (REQ-004)', () => { + const dto = makeTimelineDTO({ + years: [makeYear(1915, [letter(), historical(), curatedPersonal(), derivedLifeEvent()])] + }); + const result = filterTimeline(dto, off({ historicalOn: false })); + const kept = result.years[0].entries; + expect(kept.some((e) => e.kind === 'EVENT' && e.type === 'HISTORICAL')).toBe(false); + expect(kept.some((e) => e.kind === 'LETTER')).toBe(true); + expect(kept.some((e) => e.kind === 'EVENT' && e.type === 'PERSONAL')).toBe(true); + expect(kept).toHaveLength(3); + }); + + it('hides personal events — curated and derived — when personalOn is false (REQ-003)', () => { + const dto = makeTimelineDTO({ + years: [makeYear(1915, [letter(), historical(), curatedPersonal(), derivedLifeEvent()])] + }); + const result = filterTimeline(dto, off({ personalOn: false })); + const kept = result.years[0].entries; + // neither the curated PERSONAL event nor the derived life-event survives + expect(kept.some((e) => e.kind === 'EVENT' && e.type === 'PERSONAL')).toBe(false); + expect(kept.some((e) => e.derived)).toBe(false); + // historical events and letters are untouched + expect(kept.some((e) => e.kind === 'EVENT' && e.type === 'HISTORICAL')).toBe(true); + expect(kept.some((e) => e.kind === 'LETTER')).toBe(true); + expect(kept).toHaveLength(2); + }); + + it('drops year bands that become empty and filters the undated bucket (REQ-006)', () => { + const dto = makeTimelineDTO({ + years: [ + makeYear(1915, [letter()]), // becomes empty when letters are hidden + makeYear(1918, [historical()]) // survives + ], + undated: [letter({ documentId: 'u1' }), historical({ documentId: undefined })] + }); + const result = filterTimeline(dto, off({ lettersOn: false })); + expect(result.years).toHaveLength(1); + expect(result.years[0].year).toBe(1918); + expect(result.undated.every((e) => e.kind !== 'LETTER')).toBe(true); + expect(result.undated).toHaveLength(1); + }); + + it('does not mutate the input timeline', () => { + const dto = makeTimelineDTO({ years: [makeYear(1915, [letter(), historical()])] }); + filterTimeline(dto, off({ lettersOn: false })); + expect(dto.years[0].entries).toHaveLength(2); + }); +}); diff --git a/frontend/src/lib/timeline/timelineFilter.ts b/frontend/src/lib/timeline/timelineFilter.ts new file mode 100644 index 00000000..3273eca0 --- /dev/null +++ b/frontend/src/lib/timeline/timelineFilter.ts @@ -0,0 +1,63 @@ +import type { components } from '$lib/generated/api'; + +type TimelineDTO = components['schemas']['TimelineDTO']; +type TimelineEntryDTO = components['schemas']['TimelineEntryDTO']; + +/** + * The three visibility layers a reader can toggle on the global `/zeitstrahl` + * (#780). Purely a presentation concern — the whole timeline is loaded once by + * #779; these toggles derive a client-side filtered view of it. + */ +export interface TimelineLayerFilters { + /** Personal events — curated `PERSONAL` events and derived life-events. */ + personalOn: boolean; + /** Historical events (`type === 'HISTORICAL'`). */ + historicalOn: boolean; + /** Letters (`kind === 'LETTER'`). */ + lettersOn: boolean; +} + +/** The default view: every layer visible. */ +export const ALL_LAYERS_ON: TimelineLayerFilters = { + personalOn: true, + historicalOn: true, + lettersOn: true +}; + +/** True when no layer is hidden — the default, all-on state (REQ-007). */ +export function isDefaultState(filters: TimelineLayerFilters): boolean { + return filters.personalOn && filters.historicalOn && filters.lettersOn; +} + +/** How many layers are currently hidden — the "N active" trigger count (REQ-007). */ +export function hiddenLayerCount(filters: TimelineLayerFilters): number { + return ( + (filters.personalOn ? 0 : 1) + (filters.historicalOn ? 0 : 1) + (filters.lettersOn ? 0 : 1) + ); +} + +/** + * Decides whether one entry survives the active layer toggles. A letter rides + * the Letters layer; a historical event the Historical layer; everything else + * (curated `PERSONAL` events and derived life-events, which also carry + * `type === 'PERSONAL'`) the Personal layer. + */ +function isVisible(entry: TimelineEntryDTO, filters: TimelineLayerFilters): boolean { + if (entry.kind === 'LETTER') return filters.lettersOn; + if (entry.type === 'HISTORICAL') return filters.historicalOn; + return filters.personalOn; +} + +/** + * Derives a client-side filtered copy of the timeline (REQ-003/004/005/006). + * Year bands left empty by the active toggles are dropped so `TimelineView` + * never renders a hollow band, and the undated bucket is filtered the same way. + * Pure — the input DTO is never mutated. + */ +export function filterTimeline(timeline: TimelineDTO, filters: TimelineLayerFilters): TimelineDTO { + const years = timeline.years + .map((band) => ({ ...band, entries: band.entries.filter((e) => isVisible(e, filters)) })) + .filter((band) => band.entries.length > 0); + const undated = timeline.undated.filter((e) => isVisible(e, filters)); + return { years, undated }; +}