feat(timeline): add the client-side letter regroup transform
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 <noreply@anthropic.com>
This commit is contained in:
157
frontend/src/lib/timeline/timelineGrouping.spec.ts
Normal file
157
frontend/src/lib/timeline/timelineGrouping.spec.ts
Normal file
@@ -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<string, string>();
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
126
frontend/src/lib/timeline/timelineGrouping.ts
Normal file
126
frontend/src/lib/timeline/timelineGrouping.ts
Normal file
@@ -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<string, string> {
|
||||
const lookup = new Map<string, string>();
|
||||
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<GroupingMode, 'date'>,
|
||||
eventLookup: Map<string, string>
|
||||
): LetterBucket[] {
|
||||
const byKey = new Map<string, LetterBucket>();
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user