From ad82f2e1e2857c7eec5376949472d7f94a6a4050 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 22:06:57 +0200 Subject: [PATCH] feat(documents): add fetchDensity helper and /documents/+page.ts (#385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The density data is fetched only on tablet/desktop (sm:+ breakpoint) and when ?view=calendar is not set — mobile users and the future calendar view (#386) skip the request entirely. Lives in +page.ts (client-side) so the matchMedia gate can run in the browser; +page.server.ts continues to handle the document search. Non-ok responses and network failures degrade to an empty bucket list rather than throwing, so the document list keeps rendering. 5 unit tests cover the gating + graceful degradation paths. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/document/timeline.spec.ts | 59 +++++++++++++++++++++- frontend/src/lib/document/timeline.ts | 37 ++++++++++++++ frontend/src/routes/documents/+page.ts | 9 ++++ 3 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 frontend/src/routes/documents/+page.ts diff --git a/frontend/src/lib/document/timeline.spec.ts b/frontend/src/lib/document/timeline.spec.ts index 871eec52..ca4e177b 100644 --- a/frontend/src/lib/document/timeline.spec.ts +++ b/frontend/src/lib/document/timeline.spec.ts @@ -1,9 +1,10 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { monthBoundaryFrom, monthBoundaryTo, buildMonthSequence, - fillDensityGaps + fillDensityGaps, + fetchDensity } from './timeline'; describe('monthBoundaryFrom', () => { @@ -106,3 +107,57 @@ describe('fillDensityGaps', () => { expect(result.map((b) => b.month)).toEqual(['1915-08', '1915-09', '1915-10']); }); }); + +describe('fetchDensity', () => { + it('skips fetch and returns null density on mobile', async () => { + const fetch = vi.fn(); + + const result = await fetchDensity(fetch, null, false); + + expect(fetch).not.toHaveBeenCalled(); + expect(result).toEqual({ density: null, minDate: null, maxDate: null }); + }); + + it('skips fetch when view is calendar', async () => { + const fetch = vi.fn(); + + const result = await fetchDensity(fetch, 'calendar', true); + + expect(fetch).not.toHaveBeenCalled(); + expect(result).toEqual({ density: null, minDate: null, maxDate: null }); + }); + + it('calls /api/documents/density and returns body on desktop, list view', async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + buckets: [{ month: '1915-08', count: 3 }], + minDate: '1915-08-01', + maxDate: '1916-12-31' + }) + }); + + const result = await fetchDensity(fetch, null, true); + + expect(fetch).toHaveBeenCalledWith('/api/documents/density'); + expect(result.density).toEqual([{ month: '1915-08', count: 3 }]); + expect(result.minDate).toBe('1915-08-01'); + expect(result.maxDate).toBe('1916-12-31'); + }); + + it('returns empty density and null bounds when the API responds non-ok', async () => { + const fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 }); + + const result = await fetchDensity(fetch, null, true); + + expect(result).toEqual({ density: [], minDate: null, maxDate: null }); + }); + + it('treats fetch rejection as a graceful degradation, not an error', async () => { + const fetch = vi.fn().mockRejectedValue(new TypeError('Network down')); + + const result = await fetchDensity(fetch, null, true); + + expect(result).toEqual({ density: [], minDate: null, maxDate: null }); + }); +}); diff --git a/frontend/src/lib/document/timeline.ts b/frontend/src/lib/document/timeline.ts index 2fa09899..3406c06b 100644 --- a/frontend/src/lib/document/timeline.ts +++ b/frontend/src/lib/document/timeline.ts @@ -1,6 +1,16 @@ import type { components } from '$lib/generated/api'; type MonthBucket = components['schemas']['MonthBucket']; +type DocumentDensityResult = components['schemas']['DocumentDensityResult']; + +export type DensityState = { + density: MonthBucket[] | null; + minDate: string | null; + maxDate: string | null; +}; + +const SKIP: DensityState = { density: null, minDate: null, maxDate: null }; +const EMPTY: DensityState = { density: [], minDate: null, maxDate: null }; export function monthBoundaryFrom(yearMonth: string): string { return `${yearMonth}-01`; @@ -45,3 +55,30 @@ export function fillDensityGaps( const counts = new Map(buckets.map((b) => [b.month, b.count])); return sequence.map((month) => ({ month, count: counts.get(month) ?? 0 })); } + +/** + * 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 + * there. A non-ok response or network failure degrades to an empty bucket list + * instead of throwing, so the document list page keeps rendering. + */ +export async function fetchDensity( + fetch: typeof globalThis.fetch, + view: string | null, + isDesktop: boolean +): Promise { + if (!isDesktop || view === 'calendar') return SKIP; + + try { + const response = await fetch('/api/documents/density'); + if (!response.ok) return EMPTY; + const body = (await response.json()) as DocumentDensityResult; + return { + density: body.buckets, + minDate: body.minDate ?? null, + maxDate: body.maxDate ?? null + }; + } catch { + return EMPTY; + } +} diff --git a/frontend/src/routes/documents/+page.ts b/frontend/src/routes/documents/+page.ts new file mode 100644 index 00000000..de74bdce --- /dev/null +++ b/frontend/src/routes/documents/+page.ts @@ -0,0 +1,9 @@ +import { browser } from '$app/environment'; +import { fetchDensity } from '$lib/document/timeline'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ url, fetch }) => { + const view = url.searchParams.get('view'); + const isDesktop = browser && window.matchMedia('(min-width: 640px)').matches; + return await fetchDensity(fetch, view, isDesktop); +};