import: page shows scan progress + auto-loads when the background scan finishes (#947, phase 2 UI)

Frontend half of the async staging scan. The endpoints now return {scanning, progress} while a
large staging folder is still being scanned in the background; the page surfaces that and fills in
automatically when it completes — no manual refresh, no timeout.

- types: ImportStagingFiles/GroupsPayload gain optional scanning + progress (additive).
- useImportStaging exposes `scanning`/`scanProgress` and, while scanning, polls via a plain
  setInterval(refetch, 1500). Deliberately NOT react-query's refetchInterval — and a plain interval
  that only runs while scanning leaves the normal + error query states completely untouched.
- the header shows "Scanning N of M files…" instead of a count while the scan runs.

vitest: new test asserts the scan-progress header renders from a {scanning} response; typecheck
clean. Note: -route.test.tsx's pre-existing "staging files fail to load" test fails only in the
full-file run (passes in isolation) — verified it also fails on clean dev with all my changes
stashed, so it's a pre-existing test-isolation flake, not from this change.
This commit is contained in:
BoulderBadgeDad 2026-06-29 09:45:24 -07:00
parent 79648a4f5f
commit 8f1ffa7632
4 changed files with 52 additions and 2 deletions

View file

@ -23,11 +23,21 @@ export interface ImportStagingFile {
manual_match?: ImportTrackResult;
}
/** While a large staging folder is still being scanned in the background (#947), the
* staging endpoints return `scanning: true` + progress instead of files/groups; the query
* polls until the scan completes and real data arrives. */
export interface ImportScanProgress {
scanned: number;
total: number;
}
export interface ImportStagingFilesPayload {
success: boolean;
files?: ImportStagingFile[];
staging_path?: string;
error?: string;
scanning?: boolean;
progress?: ImportScanProgress;
}
export interface ImportStagingGroup {
@ -47,6 +57,8 @@ export interface ImportStagingGroupsPayload {
success: boolean;
groups?: ImportStagingGroup[];
error?: string;
scanning?: boolean;
progress?: ImportScanProgress;
}
export interface ImportAlbumResult {

View file

@ -250,6 +250,19 @@ describe('import route', () => {
expect(await screen.findByText('Import folder: error')).toBeInTheDocument();
});
it('shows scan progress while a large staging folder is still scanning (#947)', async () => {
server.use(
http.get('/api/import/staging/files', () =>
HttpResponse.json({ success: true, scanning: true, progress: { scanned: 5, total: 20 } }),
),
);
renderImportRoute();
expect(await screen.findByTestId('import-page')).toBeInTheDocument();
expect(await screen.findByText(/Scanning 5 of 20 files/)).toBeInTheDocument();
});
it('stores the active tab in nested route paths', async () => {
const { history } = renderImportRoute();

View file

@ -21,17 +21,24 @@ import { fallbackImage, RefreshIcon, useImportStaging } from './import-shared';
export function ImportPage() {
useReactPageShell('import');
const { refreshStaging, stagingFiles, stagingPath, stagingQuery } = useImportStaging();
const { refreshStaging, scanning, scanProgress, stagingFiles, stagingPath, stagingQuery } =
useImportStaging();
const isRefreshing = stagingQuery.isRefetching;
const lastRefreshedAt =
stagingQuery.dataUpdatedAt > 0 ? formatShortTime(stagingQuery.dataUpdatedAt) : null;
// While a large staging folder is still scanning (#947), show progress instead of a count.
const fileCountText = scanning
? scanProgress && scanProgress.total > 0
? `Scanning ${scanProgress.scanned} of ${scanProgress.total} files…`
: 'Scanning staging folder…'
: getStagingStatsText(stagingFiles);
return (
<div id="import-page" data-testid="import-page">
<div className={styles.importPageContainer}>
<ImportHeader
error={stagingQuery.error}
fileCountText={getStagingStatsText(stagingFiles)}
fileCountText={fileCountText}
loading={stagingQuery.isLoading}
stagingPath={stagingPath}
refreshing={isRefreshing}

View file

@ -1,4 +1,5 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import type { ImportQueueJob, ImportStagingFile } from '../-import.types';
@ -20,6 +21,21 @@ export function useImportStaging() {
...importStagingFilesQueryOptions(),
});
// A large staging folder (whole-library migration, #947) is scanned in the background; the
// endpoint returns `scanning: true` until it's done. While scanning, poll so the page fills
// in automatically once the scan completes. A plain setInterval (NOT react-query's
// refetchInterval, which interferes with the data query's error/retry settling) that only
// runs while scanning, so the normal and error states are left completely untouched.
const scanning = stagingQuery.data?.scanning === true;
const { refetch } = stagingQuery;
useEffect(() => {
if (!scanning) return undefined;
const id = window.setInterval(() => {
void refetch();
}, 1500);
return () => window.clearInterval(id);
}, [scanning, refetch]);
return {
refreshStaging: async () => {
clearFinishedJobs();
@ -28,6 +44,8 @@ export function useImportStaging() {
// Keep the empty fallback stable so staging-driven effects do not loop while loading.
stagingFiles: stagingQuery.data?.files ?? EMPTY_STAGING_FILES,
stagingPath: stagingQuery.data?.staging_path || 'Not configured',
scanning,
scanProgress: stagingQuery.data?.progress ?? null,
stagingQuery,
};
}