feat(timeline): layer filter (Personal / Historical / Letters) for /zeitstrahl #843
@@ -1,7 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as m from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
import TimelineView from '$lib/timeline/TimelineView.svelte';
|
import TimelineView from '$lib/timeline/TimelineView.svelte';
|
||||||
|
import TimelineFilters from '$lib/timeline/TimelineFilters.svelte';
|
||||||
import { timelineMeta } from '$lib/timeline/timelineMeta';
|
import { timelineMeta } from '$lib/timeline/timelineMeta';
|
||||||
|
import { filterTimeline } from '$lib/timeline/timelineFilter';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
@@ -9,6 +11,28 @@ let { data }: { data: PageData } = $props();
|
|||||||
const meta = $derived(timelineMeta(data.timeline));
|
const meta = $derived(timelineMeta(data.timeline));
|
||||||
const hasContent = $derived(data.timeline.years.length > 0 || data.timeline.undated.length > 0);
|
const hasContent = $derived(data.timeline.years.length > 0 || data.timeline.undated.length > 0);
|
||||||
|
|
||||||
|
// Layer-filter state (#780). Layer hiding is client-side only — the whole
|
||||||
|
// timeline is loaded once by #779's SSR load and we derive a filtered view of
|
||||||
|
// it here; there is no goto, no URL param, and no extra fetch. Known limitation
|
||||||
|
// (D1): the meta-line counts above stay on the unfiltered timeline, so they
|
||||||
|
// include entries the active toggles hide.
|
||||||
|
let personalOn = $state(true);
|
||||||
|
let historicalOn = $state(true);
|
||||||
|
let lettersOn = $state(true);
|
||||||
|
|
||||||
|
const filteredTimeline = $derived(
|
||||||
|
filterTimeline(data.timeline, { personalOn, historicalOn, lettersOn })
|
||||||
|
);
|
||||||
|
const filteredEmpty = $derived(
|
||||||
|
filteredTimeline.years.length === 0 && filteredTimeline.undated.length === 0
|
||||||
|
);
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
personalOn = true;
|
||||||
|
historicalOn = true;
|
||||||
|
lettersOn = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Compose the sub-line from segments joined by " · " so the range drops out
|
// Compose the sub-line from segments joined by " · " so the range drops out
|
||||||
// cleanly when there are no year bands; the whole line is absent when the
|
// cleanly when there are no year bands; the whole line is absent when the
|
||||||
// timeline is empty (REQ-002). Counts come from the route alone, never from
|
// timeline is empty (REQ-002). Counts come from the route alone, never from
|
||||||
@@ -51,7 +75,29 @@ const metaLine = $derived.by(() => {
|
|||||||
<h1 class="font-serif text-2xl font-bold text-brand-navy">{m.timeline_heading()}</h1>
|
<h1 class="font-serif text-2xl font-bold text-brand-navy">{m.timeline_heading()}</h1>
|
||||||
{#if hasContent}
|
{#if hasContent}
|
||||||
<p data-testid="timeline-meta" class="mt-1 mb-6 font-sans text-xs text-ink-3">{metaLine}</p>
|
<p data-testid="timeline-meta" class="mt-1 mb-6 font-sans text-xs text-ink-3">{metaLine}</p>
|
||||||
|
<TimelineFilters
|
||||||
|
bind:personalOn={personalOn}
|
||||||
|
bind:historicalOn={historicalOn}
|
||||||
|
bind:lettersOn={lettersOn}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if hasContent && filteredEmpty}
|
||||||
|
<!-- Filtered-empty: a calm message + one-click reset below the still-open
|
||||||
|
filter bar — never a blank page, and never the generic "no events"
|
||||||
|
state (which would imply the archive itself is empty). REQ-006. -->
|
||||||
|
<div data-testid="timeline-filter-empty" class="py-12 text-center">
|
||||||
|
<p class="font-serif text-base text-ink-2">{m.timeline_filter_empty_state()}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="timeline-filter-empty-reset"
|
||||||
|
onclick={resetFilters}
|
||||||
|
class="mt-3 inline-flex min-h-[44px] items-center font-sans text-sm text-primary underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
{m.timeline_filter_reset()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<TimelineView timeline={filteredTimeline} />
|
||||||
{/if}
|
{/if}
|
||||||
<TimelineView timeline={data.timeline} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect, afterEach } from 'vitest';
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
import * as m from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
import Page from './+page.svelte';
|
import Page from './+page.svelte';
|
||||||
import { makeEntry, makeYear, makeTimelineDTO } from '$lib/timeline/test-factories';
|
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 }));
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user