feat(timeline): wire the layer filter into /zeitstrahl

The route holds the three layer toggles in $state, binds them into
TimelineFilters, and derives a client-side filtered view of the SSR-loaded
timeline that it passes to TimelineView — no goto, no URL param, no extra
fetch. When the active toggles leave nothing visible it renders a calm
filtered-empty message plus a one-click reset below the still-open filter bar,
never a blank page and never the generic "no events" state. The meta-line keeps
counting the unfiltered timeline (D1 known limitation).

Refs #780
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-14 20:33:35 +02:00
committed by marcel
parent c6fe61f06b
commit e18282318a
2 changed files with 133 additions and 1 deletions

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import * as m from '$lib/paraglide/messages.js';
import Page from './+page.svelte';
import { makeEntry, makeYear, makeTimelineDTO } from '$lib/timeline/test-factories';
@@ -111,3 +112,88 @@ describe('/zeitstrahl page', () => {
expect(sub?.textContent).not.toContain(m.timeline_events_count({ count: 1 }));
});
});
describe('/zeitstrahl layer filter (#780)', () => {
const letter = (title: string, documentId: string) => makeEntry({ documentId, title });
const historical = (title: string) =>
makeEntry({
kind: 'EVENT',
type: 'HISTORICAL',
derived: false,
eventId: 'h1',
documentId: undefined,
title,
senderName: '',
receiverName: ''
});
const personal = (title: string) =>
makeEntry({
kind: 'EVENT',
type: 'PERSONAL',
derived: false,
eventId: 'p1',
documentId: undefined,
title,
senderName: '',
receiverName: ''
});
const mixed = () =>
makeTimelineDTO({
years: [
makeYear(1915, [
letter('Brief Eins', 'd1'),
historical('Erster Weltkrieg'),
personal('Umzug nach Berlin')
])
]
});
async function openBar() {
await page.getByTestId('timeline-filter-trigger').click();
}
it('hides letter cards when the Letters layer is off and restores them, with no fetch (REQ-005/002)', async () => {
render(Page, { data: pageData(mixed()) });
await expect.element(page.getByText('Brief Eins')).toBeVisible();
await openBar();
await page.getByTestId('timeline-filter-letters').click();
await expect.poll(() => page.getByText('Brief Eins').query()).toBeNull();
await expect.element(page.getByText('Umzug nach Berlin')).toBeVisible();
await page.getByTestId('timeline-filter-letters').click();
await expect.element(page.getByText('Brief Eins')).toBeVisible();
});
it('hides historical event cards when the Historical layer is off (REQ-004)', async () => {
render(Page, { data: pageData(mixed()) });
await openBar();
await page.getByTestId('timeline-filter-historical').click();
await expect.poll(() => page.getByText('Erster Weltkrieg').query()).toBeNull();
await expect.element(page.getByText('Brief Eins')).toBeVisible();
await expect.element(page.getByText('Umzug nach Berlin')).toBeVisible();
});
it('hides personal event cards when the Personal layer is off (REQ-003)', async () => {
render(Page, { data: pageData(mixed()) });
await openBar();
await page.getByTestId('timeline-filter-personal').click();
await expect.poll(() => page.getByText('Umzug nach Berlin').query()).toBeNull();
await expect.element(page.getByText('Brief Eins')).toBeVisible();
await expect.element(page.getByText('Erster Weltkrieg')).toBeVisible();
});
it('shows the filtered-empty message + reset below the open bar when all layers are off (REQ-006)', async () => {
render(Page, { data: pageData(mixed()) });
await openBar();
await page.getByTestId('timeline-filter-personal').click();
await page.getByTestId('timeline-filter-historical').click();
await page.getByTestId('timeline-filter-letters').click();
await expect.element(page.getByText(m.timeline_filter_empty_state())).toBeVisible();
await expect.element(page.getByTestId('timeline-filter-empty-reset')).toBeVisible();
// the generic TimelineView empty state is never what shows for a filtered-empty view
expect(page.getByText(m.timeline_empty_state()).query()).toBeNull();
// the one-click reset restores every layer
await page.getByTestId('timeline-filter-empty-reset').click();
await expect.element(page.getByText('Brief Eins')).toBeVisible();
});
});