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