From 5936f3a9ae8f4fdb82d60c9f70e6134635c9ebad Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 10:56:55 +0200 Subject: [PATCH] feat(timeline): wire the grouping toggle into the Zeitstrahl page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/routes/zeitstrahl/+page.svelte | 33 ++++++++++- .../src/routes/zeitstrahl/page.svelte.spec.ts | 58 +++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/frontend/src/routes/zeitstrahl/+page.svelte b/frontend/src/routes/zeitstrahl/+page.svelte index 759b3340..0c353d10 100644 --- a/frontend/src/routes/zeitstrahl/+page.svelte +++ b/frontend/src/routes/zeitstrahl/+page.svelte @@ -2,8 +2,10 @@ import * as m from '$lib/paraglide/messages.js'; import TimelineView from '$lib/timeline/TimelineView.svelte'; import TimelineFilters from '$lib/timeline/TimelineFilters.svelte'; +import GroupingControl from '$lib/timeline/GroupingControl.svelte'; import { timelineMeta } from '$lib/timeline/timelineMeta'; import { filterTimeline } from '$lib/timeline/timelineFilter'; +import { hasLooseLetters, type GroupingMode } from '$lib/timeline/timelineGrouping'; import type { PageData } from './$types'; let { data }: { data: PageData } = $props(); @@ -17,12 +19,20 @@ let personalOn = $state(true); let historicalOn = $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('date'); + const filteredTimeline = $derived( filterTimeline(data.timeline, { personalOn, historicalOn, lettersOn }) ); const filteredEmpty = $derived( 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 // 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 }) ); } - 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(' · '); }); @@ -89,7 +105,14 @@ const metaLine = $derived.by(() => { {/if} {#if hasContent} -

{metaLine}

+

{metaLine}

+ +
+ +
{ {:else} - + {/if} diff --git a/frontend/src/routes/zeitstrahl/page.svelte.spec.ts b/frontend/src/routes/zeitstrahl/page.svelte.spec.ts index 3f8a8d7b..8c79f89e 100644 --- a/frontend/src/routes/zeitstrahl/page.svelte.spec.ts +++ b/frontend/src/routes/zeitstrahl/page.svelte.spec.ts @@ -265,3 +265,61 @@ describe('/zeitstrahl curator affordances (#842)', () => { 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'); + }); +});