diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 39b11116..eda24d69 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -1051,5 +1051,8 @@ "timeline_clear_selection": "Auswahl zurücksetzen", "timeline_count_label": "Dok.", "timeline_loading": "Lade Zeitachse…", - "timeline_filtered_count": "{count} Dokumente im Zeitraum" + "timeline_filtered_count": "{count} Dokumente im Zeitraum", + "timeline_zoom_in": "Zeitraum vergrößern", + "timeline_zoom_reset": "Zurück zur Übersicht", + "timeline_dragging_aria_live": "Zeitraum {from} bis {to} ausgewählt" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 6f910726..374a5ac9 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1051,5 +1051,8 @@ "timeline_clear_selection": "Clear selection", "timeline_count_label": "docs", "timeline_loading": "Loading timeline…", - "timeline_filtered_count": "{count} documents in range" + "timeline_filtered_count": "{count} documents in range", + "timeline_zoom_in": "Zoom in", + "timeline_zoom_reset": "Reset zoom", + "timeline_dragging_aria_live": "Range {from} to {to} selected" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index dbfa346c..8a28aeb6 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1051,5 +1051,8 @@ "timeline_clear_selection": "Borrar selección", "timeline_count_label": "docs", "timeline_loading": "Cargando cronología…", - "timeline_filtered_count": "{count} documentos en el rango" + "timeline_filtered_count": "{count} documentos en el rango", + "timeline_zoom_in": "Acercar", + "timeline_zoom_reset": "Restablecer zoom", + "timeline_dragging_aria_live": "Rango {from} a {to} seleccionado" } diff --git a/frontend/src/lib/document/TimelineDensityFilter.svelte b/frontend/src/lib/document/TimelineDensityFilter.svelte index 5d859ea0..67b8d824 100644 --- a/frontend/src/lib/document/TimelineDensityFilter.svelte +++ b/frontend/src/lib/document/TimelineDensityFilter.svelte @@ -3,6 +3,7 @@ import * as m from '$lib/paraglide/messages.js'; import { fillDensityGaps, aggregateToYears, + clipBucketsToRange, selectionBoundaryFrom, selectionBoundaryTo } from '$lib/document/timeline'; @@ -10,6 +11,7 @@ import type { components } from '$lib/generated/api'; type MonthBucket = components['schemas']['MonthBucket']; type SelectionEvent = { from: string; to: string }; +type ZoomEvent = { zoomFrom: string; zoomTo: string }; const BAR_AREA_HEIGHT = 80; // px — Leonie spec h-20 const ZERO_COUNT_BAR_HEIGHT = 2; // px — minimum visible signal for empty months @@ -23,25 +25,44 @@ let { maxDate, from, to, - onchange + zoomFrom = null, + zoomTo = null, + onchange, + onzoomchange }: { density: MonthBucket[] | null; minDate: string | null; maxDate: string | null; from: string; to: string; + zoomFrom?: string | null; + zoomTo?: string | null; onchange: (event: SelectionEvent) => void; + onzoomchange?: (event: ZoomEvent | null) => void; } = $props(); const monthBuckets = $derived.by(() => { if (density === null) return []; - return fillDensityGaps(density, minDate, maxDate); + const full = fillDensityGaps(density, minDate, maxDate); + return clipBucketsToRange(full, zoomFrom, zoomTo); }); const filled = $derived( monthBuckets.length > MONTH_GRANULARITY_LIMIT ? aggregateToYears(monthBuckets) : monthBuckets ); +const isZoomed = $derived(zoomFrom !== null && zoomTo !== null); +const canZoomIn = $derived(hasSelection && !isZoomed); + +function zoomIn() { + if (from === '' || to === '') return; + onzoomchange?.({ zoomFrom: from, zoomTo: to }); +} + +function resetZoom() { + onzoomchange?.(null); +} + const maxCount = $derived(Math.max(...filled.map((b) => b.count), 1)); const hasSelection = $derived(from !== '' || to !== ''); @@ -166,17 +187,43 @@ function handleClick(index: number) { {/each} - {#if hasSelection} - - {/if} +
+ {#if canZoomIn} + + {/if} + {#if isZoomed} + + {/if} + {#if hasSelection} + + {/if} +
{/if} diff --git a/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts b/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts index 66a46d4c..bff43b7d 100644 --- a/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts +++ b/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts @@ -175,6 +175,95 @@ describe('TimelineDensityFilter — year-granularity fallback', () => { }); }); +describe('TimelineDensityFilter — zoom-in', () => { + it('does not show the zoom button when there is no selection', async () => { + render(TimelineDensityFilter, makeProps()); + expect(document.querySelector('[data-testid="timeline-zoom-in"]')).toBeNull(); + }); + + it('shows the zoom button when from/to are set and not yet zoomed', async () => { + render(TimelineDensityFilter, makeProps({ from: '1915-08-01', to: '1915-09-30' })); + await expect.element(page.getByTestId('timeline-zoom-in')).toBeInTheDocument(); + }); + + it('hides the zoom button when zoomFrom/zoomTo are already set', async () => { + render( + TimelineDensityFilter, + makeProps({ + from: '1915-08-01', + to: '1915-09-30', + zoomFrom: '1915-08-01', + zoomTo: '1915-09-30' + }) + ); + expect(document.querySelector('[data-testid="timeline-zoom-in"]')).toBeNull(); + }); + + it('clicking the zoom button emits onzoomchange with the current selection', async () => { + const onzoomchange = vi.fn(); + render( + TimelineDensityFilter, + makeProps({ from: '1915-08-01', to: '1915-09-30', onzoomchange }) + ); + + const zoomBtn = document.querySelector('[data-testid="timeline-zoom-in"]') as HTMLButtonElement; + zoomBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(onzoomchange).toHaveBeenCalledWith({ zoomFrom: '1915-08-01', zoomTo: '1915-09-30' }); + }); + + it('shows the reset-zoom button only when zoomed', async () => { + render(TimelineDensityFilter, makeProps({ zoomFrom: '1915-08-01', zoomTo: '1915-09-30' })); + await expect.element(page.getByTestId('timeline-zoom-reset')).toBeInTheDocument(); + }); + + it('clicking reset-zoom emits onzoomchange(null)', async () => { + const onzoomchange = vi.fn(); + render( + TimelineDensityFilter, + makeProps({ zoomFrom: '1915-08-01', zoomTo: '1915-09-30', onzoomchange }) + ); + + const resetBtn = document.querySelector( + '[data-testid="timeline-zoom-reset"]' + ) as HTMLButtonElement; + resetBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(onzoomchange).toHaveBeenCalledWith(null); + }); + + it('when zoomed, only bars within the zoom range are rendered', async () => { + // 21-year span normally collapses to year mode (>240 months handled + // elsewhere). Zooming in to a 3-month window should restore month bars. + const buckets: MonthBucket[] = []; + for (let year = 1900; year <= 1920; year++) { + for (let month = 1; month <= 12; month++) { + buckets.push({ + month: `${year}-${String(month).padStart(2, '0')}`, + count: 1 + }); + } + } + + render( + TimelineDensityFilter, + makeProps({ + density: buckets, + minDate: '1900-01-01', + maxDate: '1920-12-31', + zoomFrom: '1910-06-01', + zoomTo: '1910-08-31' + }) + ); + + const bars = document.querySelectorAll('[data-testid="timeline-bar"]'); + // 3 months in zoom range + expect(bars.length).toBe(3); + expect(bars[0].getAttribute('aria-label')?.startsWith('1910-06 ·')).toBe(true); + expect(bars[2].getAttribute('aria-label')?.startsWith('1910-08 ·')).toBe(true); + }); +}); + describe('TimelineDensityFilter — drag-to-select-range', () => { function pointerDown(el: HTMLElement) { const event = new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, button: 0 }); diff --git a/frontend/src/lib/document/timeline.spec.ts b/frontend/src/lib/document/timeline.spec.ts index 097705f5..2e64cbb2 100644 --- a/frontend/src/lib/document/timeline.spec.ts +++ b/frontend/src/lib/document/timeline.spec.ts @@ -8,7 +8,8 @@ import { buildDensityUrl, aggregateToYears, selectionBoundaryFrom, - selectionBoundaryTo + selectionBoundaryTo, + clipBucketsToRange } from './timeline'; describe('monthBoundaryFrom', () => { @@ -141,6 +142,37 @@ describe('aggregateToYears', () => { }); }); +describe('clipBucketsToRange', () => { + const buckets = [ + { month: '1915-08', count: 5 }, + { month: '1915-09', count: 2 }, + { month: '1915-10', count: 8 }, + { month: '1915-11', count: 3 } + ]; + + it('returns the original buckets when range bounds are null', () => { + expect(clipBucketsToRange(buckets, null, null)).toBe(buckets); + }); + + it('keeps only buckets whose month falls within the range', () => { + expect(clipBucketsToRange(buckets, '1915-09-01', '1915-10-31')).toEqual([ + { month: '1915-09', count: 2 }, + { month: '1915-10', count: 8 } + ]); + }); + + it('returns an empty array when the range excludes everything', () => { + expect(clipBucketsToRange(buckets, '1916-01-01', '1916-12-31')).toEqual([]); + }); + + it('treats partial dates correctly when bounds cross month boundaries', () => { + expect(clipBucketsToRange(buckets, '1915-09-15', '1915-10-15')).toEqual([ + { month: '1915-09', count: 2 }, + { month: '1915-10', count: 8 } + ]); + }); +}); + describe('selectionBoundaryFrom / To', () => { it('handles month labels (YYYY-MM)', () => { expect(selectionBoundaryFrom('1915-08')).toBe('1915-08-01'); diff --git a/frontend/src/lib/document/timeline.ts b/frontend/src/lib/document/timeline.ts index ac6836bb..1fbae690 100644 --- a/frontend/src/lib/document/timeline.ts +++ b/frontend/src/lib/document/timeline.ts @@ -56,6 +56,23 @@ export function fillDensityGaps( return sequence.map((month) => ({ month, count: counts.get(month) ?? 0 })); } +/** + * Returns only the month buckets whose YYYY-MM falls inside the provided + * `[fromInclusive, toInclusive]` ISO date range. When either bound is null the + * input array is returned unchanged. Used by the timeline's zoom-in tool to + * narrow the visible bars without refetching data. + */ +export function clipBucketsToRange( + buckets: MonthBucket[], + fromInclusive: string | null, + toInclusive: string | null +): MonthBucket[] { + if (!fromInclusive || !toInclusive) return buckets; + const fromMonth = fromInclusive.slice(0, 7); + const toMonth = toInclusive.slice(0, 7); + return buckets.filter((b) => b.month >= fromMonth && b.month <= toMonth); +} + /** * Aggregates month-granular buckets into one entry per year. Month strings are * truncated to "YYYY" and counts are summed. Used when the date span is too diff --git a/frontend/src/routes/documents/+page.svelte b/frontend/src/routes/documents/+page.svelte index fadb201a..e69b00a4 100644 --- a/frontend/src/routes/documents/+page.svelte +++ b/frontend/src/routes/documents/+page.svelte @@ -51,6 +51,8 @@ type FilterSnapshot = { dir: string; tagQ: string; tagOp: 'AND' | 'OR'; + zoomFrom?: string | null; + zoomTo?: string | null; }; /** @@ -72,6 +74,8 @@ function buildSearchParams(filters: FilterSnapshot, targetPage?: number): Svelte if (filters.dir) params.set('dir', filters.dir); if (filters.tagQ) params.set('tagQ', filters.tagQ); if (filters.tagOp === 'OR') params.set('tagOp', 'OR'); + if (filters.zoomFrom) params.set('zoomFrom', filters.zoomFrom); + if (filters.zoomTo) params.set('zoomTo', filters.zoomTo); if (targetPage !== undefined && targetPage > 0) params.set('page', String(targetPage)); return params; } @@ -80,7 +84,7 @@ function buildSearchParams(filters: FilterSnapshot, targetPage?: number): Svelte * Rebuilds the URL from the CURRENT local filter state. `page` is intentionally * not carried over — any filter change implicitly resets back to page 0. */ -function triggerSearch() { +function triggerSearch(zoomOverride?: { zoomFrom: string | null; zoomTo: string | null }) { const params = buildSearchParams({ q, from, @@ -91,7 +95,9 @@ function triggerSearch() { sort, dir, tagQ, - tagOp: tagOperator + tagOp: tagOperator, + zoomFrom: zoomOverride ? zoomOverride.zoomFrom : data.zoomFrom, + zoomTo: zoomOverride ? zoomOverride.zoomTo : data.zoomTo }); goto(`/documents?${params.toString()}`, { keepFocus: true, noScroll: true }); } @@ -240,6 +246,8 @@ $effect(() => { density={data.density} minDate={data.minDate} maxDate={data.maxDate} + zoomFrom={data.zoomFrom} + zoomTo={data.zoomTo} from={from} to={to} onchange={(event) => { @@ -247,6 +255,12 @@ $effect(() => { to = event.to; triggerSearch(); }} + onzoomchange={(event) => { + triggerSearch({ + zoomFrom: event?.zoomFrom ?? null, + zoomTo: event?.zoomTo ?? null + }); + }} /> diff --git a/frontend/src/routes/documents/+page.ts b/frontend/src/routes/documents/+page.ts index d79459bb..dace6efe 100644 --- a/frontend/src/routes/documents/+page.ts +++ b/frontend/src/routes/documents/+page.ts @@ -19,5 +19,8 @@ export const load: PageLoad = async ({ url, fetch, data }) => { }; const density = await fetchDensity(fetch, view, isDesktop, filters); - return { ...data, ...density }; + const zoomFrom = url.searchParams.get('zoomFrom'); + const zoomTo = url.searchParams.get('zoomTo'); + + return { ...data, ...density, zoomFrom, zoomTo }; }; diff --git a/frontend/src/routes/documents/page.svelte.spec.ts b/frontend/src/routes/documents/page.svelte.spec.ts index e0c0f117..2016f894 100644 --- a/frontend/src/routes/documents/page.svelte.spec.ts +++ b/frontend/src/routes/documents/page.svelte.spec.ts @@ -176,4 +176,55 @@ describe('documents page — timeline density widget', () => { expect(url).toContain('from=1915-08-01'); expect(url).toContain('to=1915-08-31'); }); + + it('clicking the zoom-in button writes zoomFrom/zoomTo URL params', async () => { + const { goto } = await import('$app/navigation'); + vi.mocked(goto).mockClear(); + + render(Page, { + data: makeData({ + density: [ + { month: '1915-08', count: 3 }, + { month: '1915-09', count: 2 } + ], + minDate: '1915-08-01', + maxDate: '1915-09-30', + from: '1915-08-01', + to: '1915-09-30' + }) + }); + + const zoomBtn = document.querySelector('[data-testid="timeline-zoom-in"]') as HTMLButtonElement; + zoomBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(goto).toHaveBeenCalledOnce(); + const [url] = vi.mocked(goto).mock.calls[0]; + expect(url).toContain('zoomFrom=1915-08-01'); + expect(url).toContain('zoomTo=1915-09-30'); + }); + + it('clicking reset-zoom drops zoomFrom/zoomTo from the URL', async () => { + const { goto } = await import('$app/navigation'); + vi.mocked(goto).mockClear(); + + render(Page, { + data: makeData({ + density: [{ month: '1915-08', count: 3 }], + minDate: '1915-08-01', + maxDate: '1915-08-31', + zoomFrom: '1915-08-01', + zoomTo: '1915-08-31' + }) + }); + + const resetBtn = document.querySelector( + '[data-testid="timeline-zoom-reset"]' + ) as HTMLButtonElement; + resetBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(goto).toHaveBeenCalledOnce(); + const [url] = vi.mocked(goto).mock.calls[0]; + expect(url).not.toContain('zoomFrom='); + expect(url).not.toContain('zoomTo='); + }); });