feat(timeline): wire the grouping toggle into the Zeitstrahl page
Add the grouping $state beside the #780 layer-filter state, render the GroupingControl stacked above the filter trigger (disabled, but kept in place, when no loose letters remain), make the meta-line grouping label track the active mode, and thread groupingMode into TimelineView — filter-then-group, no refetch. Refs #827 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,10 @@
|
|||||||
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 TimelineFilters from '$lib/timeline/TimelineFilters.svelte';
|
||||||
|
import GroupingControl from '$lib/timeline/GroupingControl.svelte';
|
||||||
import { timelineMeta } from '$lib/timeline/timelineMeta';
|
import { timelineMeta } from '$lib/timeline/timelineMeta';
|
||||||
import { filterTimeline } from '$lib/timeline/timelineFilter';
|
import { filterTimeline } from '$lib/timeline/timelineFilter';
|
||||||
|
import { hasLooseLetters, type GroupingMode } from '$lib/timeline/timelineGrouping';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
@@ -17,12 +19,20 @@ let personalOn = $state(true);
|
|||||||
let historicalOn = $state(true);
|
let historicalOn = $state(true);
|
||||||
let lettersOn = $state(true);
|
let lettersOn = $state(true);
|
||||||
|
|
||||||
|
// Grouping state (#827) lives here beside the layer-filter state; the regroup is a
|
||||||
|
// pure client-side transform over the already-filtered view — filter-then-group.
|
||||||
|
let groupingMode = $state<GroupingMode>('date');
|
||||||
|
|
||||||
const filteredTimeline = $derived(
|
const filteredTimeline = $derived(
|
||||||
filterTimeline(data.timeline, { personalOn, historicalOn, lettersOn })
|
filterTimeline(data.timeline, { personalOn, historicalOn, lettersOn })
|
||||||
);
|
);
|
||||||
const filteredEmpty = $derived(
|
const filteredEmpty = $derived(
|
||||||
filteredTimeline.years.length === 0 && filteredTimeline.undated.length === 0
|
filteredTimeline.years.length === 0 && filteredTimeline.undated.length === 0
|
||||||
);
|
);
|
||||||
|
// The grouping control is only meaningful while loose letters remain in the filtered
|
||||||
|
// view; with the Letters layer off there is nothing to regroup, so it disables but
|
||||||
|
// keeps its selected mode (REQ-018).
|
||||||
|
const hasLetters = $derived(hasLooseLetters(filteredTimeline));
|
||||||
|
|
||||||
// Meta-line figures track the *filtered* view, so the header counts always
|
// Meta-line figures track the *filtered* view, so the header counts always
|
||||||
// match what is actually on screen once layers are toggled off (#780 — this
|
// match what is actually on screen once layers are toggled off (#780 — this
|
||||||
@@ -60,7 +70,13 @@ const metaLine = $derived.by(() => {
|
|||||||
: m.timeline_events_count({ count: meta.eventCount })
|
: m.timeline_events_count({ count: meta.eventCount })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
segments.push(m.timeline_grouping_date());
|
segments.push(
|
||||||
|
groupingMode === 'event'
|
||||||
|
? m.timeline_grouping_event()
|
||||||
|
: groupingMode === 'thema'
|
||||||
|
? m.timeline_grouping_thema()
|
||||||
|
: m.timeline_grouping_date()
|
||||||
|
);
|
||||||
return segments.join(' · ');
|
return segments.join(' · ');
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -89,7 +105,14 @@ const metaLine = $derived.by(() => {
|
|||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
{#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-3 font-sans text-xs text-ink-3">{metaLine}</p>
|
||||||
|
<!-- Grouping toggle stacked above the #780 layer-filter trigger so the two read as
|
||||||
|
one control cluster in the header (REQ-010); the top-right corner stays the
|
||||||
|
add-event CTA. Disabled — but kept in place — when no loose letters remain
|
||||||
|
(REQ-018). -->
|
||||||
|
<div class="mb-3" data-testid="grouping-cluster">
|
||||||
|
<GroupingControl bind:mode={groupingMode} disabled={!hasLetters} />
|
||||||
|
</div>
|
||||||
<TimelineFilters
|
<TimelineFilters
|
||||||
bind:personalOn={personalOn}
|
bind:personalOn={personalOn}
|
||||||
bind:historicalOn={historicalOn}
|
bind:historicalOn={historicalOn}
|
||||||
@@ -112,7 +135,11 @@ const metaLine = $derived.by(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<TimelineView timeline={filteredTimeline} canWrite={data.canWrite} />
|
<TimelineView
|
||||||
|
timeline={filteredTimeline}
|
||||||
|
canWrite={data.canWrite}
|
||||||
|
groupingMode={groupingMode}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -265,3 +265,61 @@ describe('/zeitstrahl curator affordances (#842)', () => {
|
|||||||
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
|
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('/zeitstrahl grouping toggle (#827)', () => {
|
||||||
|
const historical = () =>
|
||||||
|
makeEntry({
|
||||||
|
kind: 'EVENT',
|
||||||
|
type: 'HISTORICAL',
|
||||||
|
derived: false,
|
||||||
|
eventId: 'h1',
|
||||||
|
documentId: undefined,
|
||||||
|
title: 'Erster Weltkrieg',
|
||||||
|
senderName: '',
|
||||||
|
receiverName: ''
|
||||||
|
});
|
||||||
|
const mixed = () =>
|
||||||
|
makeTimelineDTO({
|
||||||
|
years: [
|
||||||
|
makeYear(1915, [
|
||||||
|
makeEntry({ documentId: 'd1', title: 'Brief Eins', linkedEventId: 'h1' }),
|
||||||
|
historical()
|
||||||
|
])
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const radio = (value: string) => document.querySelector(`[data-value="${value}"]`) as HTMLElement;
|
||||||
|
|
||||||
|
it('updates the meta-line grouping label when a mode is chosen (REQ-016)', async () => {
|
||||||
|
render(Page, { data: pageData(mixed()) });
|
||||||
|
const meta = page.getByTestId('timeline-meta');
|
||||||
|
await expect.element(meta).toHaveTextContent(m.timeline_grouping_date());
|
||||||
|
radio('event').click();
|
||||||
|
await expect.element(meta).toHaveTextContent(m.timeline_grouping_event());
|
||||||
|
radio('thema').click();
|
||||||
|
await expect.element(meta).toHaveTextContent(m.timeline_grouping_thema());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('regroups loose letters under their event client-side, no buckets in Datum (REQ-002/003)', async () => {
|
||||||
|
render(Page, { data: pageData(mixed()) });
|
||||||
|
expect(document.querySelector('[data-testid="letter-bucket"]')).toBeNull();
|
||||||
|
radio('event').click();
|
||||||
|
await expect.element(page.getByTestId('letter-bucket')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the grouping control when the Letters layer is off, keeping the mode (REQ-018)', async () => {
|
||||||
|
render(Page, { data: pageData(mixed()) });
|
||||||
|
radio('thema').click();
|
||||||
|
const control = page.getByTestId('grouping-control');
|
||||||
|
await expect.element(control).toHaveAttribute('aria-disabled', 'false');
|
||||||
|
// turn the Letters layer off → nothing to regroup
|
||||||
|
await page.getByTestId('timeline-filter-trigger').click();
|
||||||
|
await page.getByTestId('timeline-filter-letters').click();
|
||||||
|
await expect.element(control).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
// the chosen mode is retained for when letters return
|
||||||
|
expect(radio('thema').getAttribute('aria-checked')).toBe('true');
|
||||||
|
// re-enabling restores the enabled control with the same mode (no reset to Datum)
|
||||||
|
await page.getByTestId('timeline-filter-letters').click();
|
||||||
|
await expect.element(control).toHaveAttribute('aria-disabled', 'false');
|
||||||
|
expect(radio('thema').getAttribute('aria-checked')).toBe('true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user