feat(documents): add fetchDensity helper and /documents/+page.ts (#385)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,10 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
monthBoundaryFrom,
|
monthBoundaryFrom,
|
||||||
monthBoundaryTo,
|
monthBoundaryTo,
|
||||||
buildMonthSequence,
|
buildMonthSequence,
|
||||||
fillDensityGaps
|
fillDensityGaps,
|
||||||
|
fetchDensity
|
||||||
} from './timeline';
|
} from './timeline';
|
||||||
|
|
||||||
describe('monthBoundaryFrom', () => {
|
describe('monthBoundaryFrom', () => {
|
||||||
@@ -106,3 +107,57 @@ describe('fillDensityGaps', () => {
|
|||||||
expect(result.map((b) => b.month)).toEqual(['1915-08', '1915-09', '1915-10']);
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
type MonthBucket = components['schemas']['MonthBucket'];
|
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 {
|
export function monthBoundaryFrom(yearMonth: string): string {
|
||||||
return `${yearMonth}-01`;
|
return `${yearMonth}-01`;
|
||||||
@@ -45,3 +55,30 @@ export function fillDensityGaps(
|
|||||||
const counts = new Map(buckets.map((b) => [b.month, b.count]));
|
const counts = new Map(buckets.map((b) => [b.month, b.count]));
|
||||||
return sequence.map((month) => ({ month, count: counts.get(month) ?? 0 }));
|
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<DensityState> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
9
frontend/src/routes/documents/+page.ts
Normal file
9
frontend/src/routes/documents/+page.ts
Normal file
@@ -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);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user