feat(documents): zoom-in tool for the timeline (#385)

Adds a zoom action that narrows the visible timeline range to the current
selection so the user can drill from year-level back into month-level
density. Zoom state lives in the URL (zoomFrom / zoomTo) so it survives
reload and is shareable.

- New `clipBucketsToRange(buckets, from, to)` helper applied before the
  >240-month year-aggregate decision, so a zoomed window flips back to
  month bars automatically when the clip narrows the range enough.
- `TimelineDensityFilter` gains `zoomFrom`, `zoomTo`, and `onzoomchange`
  props. Zoom button shown only when a selection exists and we aren't
  already zoomed; reset-zoom shown only when zoomed. Both placed in a
  shared right-edge action cluster alongside the × clear button.
- `+page.ts` reads zoomFrom/zoomTo from the URL and forwards them as
  props. `+page.svelte` extends FilterSnapshot + buildSearchParams, and
  triggerSearch accepts an optional zoom override so the onzoomchange
  callback can write the new pair (or clear them) atomically.
- 7 new component tests + 2 new page-integration tests cover the
  visibility rules and URL writes.
- 4 new unit tests for `clipBucketsToRange`.
- 3 new i18n keys (zoom in / zoom reset / drag aria-live) across de/en/es.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-07 23:23:38 +02:00
parent bd81ff81f9
commit a6123e1867
10 changed files with 282 additions and 20 deletions

View File

@@ -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
});
}}
/>
</div>

View File

@@ -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 };
};

View File

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