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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-14 20:17:02 +02:00
committed by marcel
parent 8558567688
commit dc9d1d52b3
2 changed files with 214 additions and 0 deletions

View File

@@ -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>): 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);
});
});

View File

@@ -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 };
}