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=');
+ });
});