perfect-postcode/frontend/src/components/map/map-page/useExportController.ts
2026-07-12 15:03:33 +01:00

239 lines
8 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import type { TFunction } from 'i18next';
import type { Bounds, FeatureFilters, FeatureMeta } from '../../../types';
import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../../lib/api';
import { trackEvent } from '../../../lib/analytics';
import type { ExportNotice, ExportState } from './types';
import { resolveTransitVariant, type TravelTimeEntry } from '../../../hooks/useTravelTime';
import { buildTravelParam, dedupeTravelTimeEntries } from '../../../lib/travel-params';
import type { OverlayId } from '../../../lib/overlays';
const EXPORT_FILE_NAME = 'perfect-postcode-export.xlsx';
const EXPORT_TIMEOUT_MS = 150_000;
const EXPORT_NOTICE_MS = 6000;
const EXPORT_ERROR_NOTICE_MS = 9000;
function getExportFileName(res: Response): string {
const disposition = res.headers.get('content-disposition');
if (!disposition) return EXPORT_FILE_NAME;
const encodedMatch = disposition.match(/filename\*=UTF-8''([^;]+)/i);
if (encodedMatch?.[1]) {
try {
return decodeURIComponent(encodedMatch[1].trim());
} catch {
return encodedMatch[1].trim();
}
}
const match = disposition.match(/filename="?([^";]+)"?/i);
return match?.[1]?.trim() || EXPORT_FILE_NAME;
}
async function getExportErrorMessage(res: Response): Promise<string> {
const fallback = `HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ''}`;
const contentType = res.headers.get('content-type') ?? '';
try {
if (contentType.includes('application/json')) {
const data: unknown = await res.json();
if (data && typeof data === 'object') {
const record = data as Record<string, unknown>;
const message = record.message ?? record.error;
if (typeof message === 'string' && message.trim()) return message.trim();
}
return fallback;
}
const text = await res.text();
return text.trim() || fallback;
} catch {
return fallback;
}
}
function triggerExportDownload(blob: Blob, fileName: string): void {
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = fileName;
link.rel = 'noopener';
link.style.display = 'none';
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
}
function appendTravelStateParams(params: URLSearchParams, entries: TravelTimeEntry[]): void {
for (const entry of dedupeTravelTimeEntries(entries)) {
if (!entry.slug) continue;
const serverMode = resolveTransitVariant(entry);
let value = `${serverMode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
if (entry.useBest) value += ':b';
if (entry.timeRange) {
value += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
}
params.append('tt', value);
}
}
interface UseExportControllerOptions {
bounds: Bounds | null;
filters: FeatureFilters;
features: FeatureMeta[];
travelTimeEntries: TravelTimeEntry[];
selectedOverlays: Set<OverlayId>;
shareCode?: string;
t: TFunction;
onExportStateChange?: (state: ExportState) => void;
}
export function useExportController({
bounds,
filters,
features,
travelTimeEntries,
selectedOverlays,
shareCode,
t,
onExportStateChange,
}: UseExportControllerOptions) {
const [exporting, setExporting] = useState(false);
const [exportNotice, setExportNotice] = useState<ExportNotice | null>(null);
const exportNoticeTimeoutRef = useRef<number | null>(null);
const clearExportNoticeTimer = useCallback(() => {
if (exportNoticeTimeoutRef.current !== null) {
window.clearTimeout(exportNoticeTimeoutRef.current);
exportNoticeTimeoutRef.current = null;
}
}, []);
const clearExportNotice = useCallback(() => {
clearExportNoticeTimer();
setExportNotice(null);
}, [clearExportNoticeTimer]);
const showExportNotice = useCallback(
(notice: ExportNotice) => {
clearExportNoticeTimer();
setExportNotice(notice);
exportNoticeTimeoutRef.current = window.setTimeout(
() => {
setExportNotice(null);
exportNoticeTimeoutRef.current = null;
},
notice.kind === 'error' ? EXPORT_ERROR_NOTICE_MS : EXPORT_NOTICE_MS
);
},
[clearExportNoticeTimer]
);
useEffect(() => clearExportNoticeTimer, [clearExportNoticeTimer]);
const handleExport = useCallback(
(options?: { postcodes?: string[] }) => {
if (exporting) return;
const postcodeList = options?.postcodes?.map((p) => p.trim()).filter(Boolean) ?? [];
const isListMode = postcodeList.length > 0;
if (!isListMode && !bounds) {
showExportNotice({ kind: 'error', message: t('header.exportUnavailable') });
return;
}
const params = new URLSearchParams();
if (isListMode) {
params.set('postcodes', postcodeList.join(','));
// Pass the active filters so the export keeps its two sheets, "Selected"
// (the filtered feature columns) and "All Data", even in list mode. The
// server uses them only to pick columns; the supplied postcodes still
// drive which rows appear.
const filterStr = buildFilterString(filters, features);
if (filterStr) params.set('filters', filterStr);
// Travel times are per-postcode, so include them as columns here too.
// The server ignores the time-range filter in list mode (the supplied
// postcodes drive the rows), but still shows each one's travel time.
const travelParam = buildTravelParam(travelTimeEntries);
if (travelParam) params.set('travel', travelParam);
appendTravelStateParams(params, travelTimeEntries);
if (shareCode) params.set('share', shareCode);
} else {
const { south, west, north, east } = bounds!;
params.set('bounds', `${south},${west},${north},${east}`);
const filterStr = buildFilterString(filters, features);
if (filterStr) params.set('filters', filterStr);
const travelParam = buildTravelParam(travelTimeEntries);
if (travelParam) params.set('travel', travelParam);
appendTravelStateParams(params, travelTimeEntries);
for (const overlay of selectedOverlays) {
params.append('overlay', overlay);
}
if (shareCode) params.set('share', shareCode);
}
const url = apiUrl('export', params);
const controller = new AbortController();
let timedOut = false;
const timeoutId = window.setTimeout(() => {
timedOut = true;
controller.abort();
}, EXPORT_TIMEOUT_MS);
setExporting(true);
clearExportNotice();
void (async () => {
try {
const res = await fetch(url, authHeaders({ signal: controller.signal }));
if (!res.ok) throw new Error(await getExportErrorMessage(res));
const blob = await res.blob();
if (blob.size === 0) throw new Error(t('header.exportEmpty'));
triggerExportDownload(blob, getExportFileName(res));
trackEvent('Export');
showExportNotice({ kind: 'success', message: t('header.exportReady') });
} catch (err) {
if (!timedOut) logNonAbortError('Export failed', err);
const detail = err instanceof Error && err.message.trim() ? ` ${err.message}` : '';
showExportNotice({
kind: 'error',
message: timedOut ? t('header.exportTimedOut') : `${t('header.exportFailed')}${detail}`,
});
} finally {
window.clearTimeout(timeoutId);
setExporting(false);
}
})();
},
[
bounds,
clearExportNotice,
exporting,
features,
filters,
selectedOverlays,
shareCode,
showExportNotice,
t,
travelTimeEntries,
]
);
useEffect(() => {
onExportStateChange?.({ onExport: handleExport, exporting });
}, [handleExport, exporting, onExportStateChange]);
return {
exporting,
exportNotice,
clearExportNotice,
handleExport,
};
}