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:
151
frontend/src/lib/timeline/timelineFilter.spec.ts
Normal file
151
frontend/src/lib/timeline/timelineFilter.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
63
frontend/src/lib/timeline/timelineFilter.ts
Normal file
63
frontend/src/lib/timeline/timelineFilter.ts
Normal 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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user