fix(documents): collapse timeline to year bars when range > 240 months (#385)

Surfaced during proofshot: the production archive spans 1873 → 2023
(≈1809 month bars). With flex-1 + gap-px on a 1280 px container, every
pixel was consumed by gaps and bars rendered at 0 px width — visible as
"empty box, no bars".

Fix:
- Add aggregateToYears(buckets) that sums month counts per year and
  returns YYYY-keyed entries.
- Add selectionBoundaryFrom/To that handle both YYYY and YYYY-MM labels
  (Jan 1 → Dec 31 for years, first → last day for months).
- Component switches to year granularity when the gap-filled month
  sequence exceeds 240 entries (~20 years), keeping each bar clickable.
- Drop the gap-px between bars and add min-w-px so sub-pixel rounding
  still leaves something visible.

5 new tests cover aggregation, boundary helpers, and the component-level
year-mode + click behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-07 22:54:02 +02:00
parent 8e29f428d7
commit 59a2faa145
4 changed files with 139 additions and 11 deletions

View File

@@ -1,6 +1,11 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { fillDensityGaps, monthBoundaryFrom, monthBoundaryTo } from '$lib/document/timeline';
import {
fillDensityGaps,
aggregateToYears,
selectionBoundaryFrom,
selectionBoundaryTo
} from '$lib/document/timeline';
import type { components } from '$lib/generated/api';
type MonthBucket = components['schemas']['MonthBucket'];
@@ -8,6 +13,9 @@ type SelectionEvent = { from: string; to: string };
const BAR_AREA_HEIGHT = 80; // px — Leonie spec h-20
const ZERO_COUNT_BAR_HEIGHT = 2; // px — minimum visible signal for empty months
// Above this threshold, month bars compress to sub-pixel widths in the flex
// row; we collapse to year granularity so each bar stays clickable.
const MONTH_GRANULARITY_LIMIT = 240;
let {
density,
@@ -25,11 +33,15 @@ let {
onchange: (event: SelectionEvent) => void;
} = $props();
const filled = $derived.by(() => {
const monthBuckets = $derived.by(() => {
if (density === null) return [];
return fillDensityGaps(density, minDate, maxDate);
});
const filled = $derived(
monthBuckets.length > MONTH_GRANULARITY_LIMIT ? aggregateToYears(monthBuckets) : monthBuckets
);
const maxCount = $derived(Math.max(...filled.map((b) => b.count), 1));
const hasSelection = $derived(from !== '' || to !== '');
@@ -39,18 +51,18 @@ function barHeight(count: number): number {
return Math.max(ZERO_COUNT_BAR_HEIGHT, (count / maxCount) * BAR_AREA_HEIGHT);
}
function selectMonth(month: string) {
onchange({ from: monthBoundaryFrom(month), to: monthBoundaryTo(month) });
function selectBar(label: string) {
onchange({ from: selectionBoundaryFrom(label), to: selectionBoundaryTo(label) });
}
function clearSelection() {
onchange({ from: '', to: '' });
}
function isSelected(month: string): boolean {
function isSelected(label: string): boolean {
if (!hasSelection) return false;
const monthFrom = monthBoundaryFrom(month);
return monthFrom >= from && monthFrom <= to;
const labelFrom = selectionBoundaryFrom(label);
return labelFrom >= from && labelFrom <= to;
}
</script>
@@ -61,15 +73,15 @@ function isSelected(month: string): boolean {
aria-label={m.timeline_aria_label()}
class="relative rounded-sm border border-line bg-surface px-3 pt-3 pb-2 shadow-sm"
>
<div class="flex h-20 items-end gap-px" style="height: {BAR_AREA_HEIGHT}px;">
<div class="flex h-20 items-end" style="height: {BAR_AREA_HEIGHT}px;">
{#each filled as bucket (bucket.month)}
<button
type="button"
data-testid="timeline-bar"
aria-label="{bucket.month} · {bucket.count}"
aria-pressed={isSelected(bucket.month)}
onclick={() => selectMonth(bucket.month)}
class="bar group flex flex-1 cursor-pointer items-end justify-center bg-transparent p-0 transition-colors"
onclick={() => selectBar(bucket.month)}
class="bar group flex min-w-px flex-1 cursor-pointer items-end justify-center bg-transparent p-0 transition-colors"
class:selected={isSelected(bucket.month)}
>
<span

View File

@@ -130,3 +130,46 @@ describe('TimelineDensityFilter — selection', () => {
expect(clearBtn.getAttribute('aria-label')).toBeTruthy();
});
});
describe('TimelineDensityFilter — year-granularity fallback', () => {
it('collapses to year buckets when the month sequence exceeds the limit', async () => {
// 21 years × 12 months = 252 entries — above the 240 month limit.
const buckets: MonthBucket[] = [];
for (let year = 1900; year <= 1920; year++) {
buckets.push({ month: `${year}-06`, count: year - 1899 });
}
render(
TimelineDensityFilter,
makeProps({ density: buckets, minDate: '1900-01-01', maxDate: '1920-12-31' })
);
const bars = document.querySelectorAll('[data-testid="timeline-bar"]');
expect(bars.length).toBe(21);
const firstLabel = bars[0].getAttribute('aria-label');
expect(firstLabel?.startsWith('1900 ·')).toBe(true);
});
it('clicking a year bar emits Jan 1 to Dec 31 boundary dates', async () => {
const onchange = vi.fn();
const buckets: MonthBucket[] = [];
for (let year = 1900; year <= 1920; year++) {
buckets.push({ month: `${year}-06`, count: 5 });
}
render(
TimelineDensityFilter,
makeProps({
density: buckets,
minDate: '1900-01-01',
maxDate: '1920-12-31',
onchange
})
);
const bars = document.querySelectorAll(
'[data-testid="timeline-bar"]'
) as NodeListOf<HTMLElement>;
bars[5].dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(onchange).toHaveBeenCalledWith({ from: '1905-01-01', to: '1905-12-31' });
});
});

View File

@@ -4,7 +4,10 @@ import {
monthBoundaryTo,
buildMonthSequence,
fillDensityGaps,
fetchDensity
fetchDensity,
aggregateToYears,
selectionBoundaryFrom,
selectionBoundaryTo
} from './timeline';
describe('monthBoundaryFrom', () => {
@@ -108,6 +111,47 @@ describe('fillDensityGaps', () => {
});
});
describe('aggregateToYears', () => {
it('returns empty array for empty input', () => {
expect(aggregateToYears([])).toEqual([]);
});
it('sums counts within the same year', () => {
const result = aggregateToYears([
{ month: '1915-08', count: 5 },
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 8 }
]);
expect(result).toEqual([{ month: '1915', count: 15 }]);
});
it('produces one bucket per distinct year, sorted chronologically', () => {
const result = aggregateToYears([
{ month: '1916-01', count: 3 },
{ month: '1915-08', count: 5 },
{ month: '1916-04', count: 7 },
{ month: '1914-12', count: 1 }
]);
expect(result).toEqual([
{ month: '1914', count: 1 },
{ month: '1915', count: 5 },
{ month: '1916', count: 10 }
]);
});
});
describe('selectionBoundaryFrom / To', () => {
it('handles month labels (YYYY-MM)', () => {
expect(selectionBoundaryFrom('1915-08')).toBe('1915-08-01');
expect(selectionBoundaryTo('1915-08')).toBe('1915-08-31');
});
it('handles year labels (YYYY)', () => {
expect(selectionBoundaryFrom('1915')).toBe('1915-01-01');
expect(selectionBoundaryTo('1915')).toBe('1915-12-31');
});
});
describe('fetchDensity', () => {
it('skips fetch and returns null density on mobile', async () => {
const fetch = vi.fn();

View File

@@ -56,6 +56,35 @@ export function fillDensityGaps(
return sequence.map((month) => ({ month, count: counts.get(month) ?? 0 }));
}
/**
* 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
* long for month-granular bars to render at a clickable size.
*/
export function aggregateToYears(buckets: MonthBucket[]): MonthBucket[] {
const totals = new Map<string, number>();
for (const b of buckets) {
const year = b.month.slice(0, 4);
totals.set(year, (totals.get(year) ?? 0) + b.count);
}
return Array.from(totals.entries())
.map(([year, count]) => ({ month: year, count }))
.sort((a, b) => a.month.localeCompare(b.month));
}
/**
* Boundary helpers for selection. Accept either "YYYY-MM" (month) or "YYYY"
* (year) and return the matching LocalDate string.
*/
export function selectionBoundaryFrom(label: string): string {
return label.length === 4 ? `${label}-01-01` : `${label}-01`;
}
export function selectionBoundaryTo(label: string): string {
if (label.length === 4) return `${label}-12-31`;
return monthBoundaryTo(label);
}
/**
* Loads the density data for the timeline widget. Mobile (sm: breakpoint and below)
* and calendar view both skip the request entirely — the widget isn't rendered