feat(web/m7-scan-progress): StageBadge + 4-card layout updates
Each of the 4 stage cards in the admin Library Scan section gains a StageBadge in its header. The badge derives state via stageState() from the existing ScanStatus payload — no new query, no schema change. Active stage shows spinner + "Processing" in moss green; done shows check + "Done" in moss green; pending and skipped show muted text. Card body now shows either the (possibly partial) numbers OR a "Waiting for previous stage…" placeholder when the badge is pending. The previous bottom-of-card "Pending…" / "Skipped." fallback text disappears (the badge replaces it). StageBadge.test.ts covers the four state renderings. admin.test.ts adds one new case asserting the Processing badge shows when scan.library is populated and in_flight: true. Existing admin.test.ts assertions still pass (the default mock data has no stage tallies + in_flight: false → all stages skipped, no Loader/ Check icons rendered).
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { Loader2, Check } from 'lucide-svelte';
|
||||
|
||||
type Props = { state: 'pending' | 'in_progress' | 'done' | 'skipped' };
|
||||
let { state }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if state === 'in_progress'}
|
||||
<span class="inline-flex items-center gap-1 text-xs text-action-primary">
|
||||
<Loader2 size={12} class="animate-spin" />
|
||||
Processing
|
||||
</span>
|
||||
{:else if state === 'done'}
|
||||
<span class="inline-flex items-center gap-1 text-xs text-action-primary">
|
||||
<Check size={12} />
|
||||
Done
|
||||
</span>
|
||||
{:else if state === 'pending'}
|
||||
<span class="text-xs text-text-muted">Pending</span>
|
||||
{:else}
|
||||
<span class="text-xs text-text-muted">Skipped</span>
|
||||
{/if}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import StageBadge from './StageBadge.svelte';
|
||||
|
||||
describe('StageBadge', () => {
|
||||
it('renders Processing + spinner when state is in_progress', () => {
|
||||
render(StageBadge, { props: { state: 'in_progress' } });
|
||||
expect(screen.getByText('Processing')).toBeInTheDocument();
|
||||
// The spinner is a lucide icon — just confirm a child SVG exists.
|
||||
expect(document.querySelector('svg')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders Done + check when state is done', () => {
|
||||
render(StageBadge, { props: { state: 'done' } });
|
||||
expect(screen.getByText('Done')).toBeInTheDocument();
|
||||
expect(document.querySelector('svg')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders Pending muted when state is pending', () => {
|
||||
render(StageBadge, { props: { state: 'pending' } });
|
||||
expect(screen.getByText('Pending')).toBeInTheDocument();
|
||||
// No icon for the muted states.
|
||||
expect(document.querySelector('svg')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('renders Skipped muted when state is skipped', () => {
|
||||
render(StageBadge, { props: { state: 'skipped' } });
|
||||
expect(screen.getByText('Skipped')).toBeInTheDocument();
|
||||
expect(document.querySelector('svg')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '$lib/branding';
|
||||
import { Disc3, Album, Music2, Check, X, RotateCcw, Trash2, Cloud, ChevronRight } from 'lucide-svelte';
|
||||
import StageBadge from '$lib/components/StageBadge.svelte';
|
||||
import { stageState } from './stage-state';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import {
|
||||
createAdminRequestsQuery,
|
||||
@@ -346,7 +348,10 @@
|
||||
<div class="mt-3 grid gap-3 sm:grid-cols-4">
|
||||
<!-- Library stage -->
|
||||
<div class="rounded border border-border bg-bg p-3">
|
||||
<div class="text-xs font-medium uppercase tracking-wide text-text-muted">Library walk</div>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<div class="text-xs font-medium uppercase tracking-wide text-text-muted">Library walk</div>
|
||||
<StageBadge state={stageState(scan, 'library')} />
|
||||
</div>
|
||||
{#if scan.library}
|
||||
<dl class="mt-2 space-y-0.5 text-sm">
|
||||
<div class="flex justify-between"><dt class="text-text-secondary">Scanned</dt><dd>{scan.library.scanned}</dd></div>
|
||||
@@ -355,14 +360,17 @@
|
||||
<div class="flex justify-between"><dt class="text-text-secondary">Skipped</dt><dd>{scan.library.skipped}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-text-secondary">Errored</dt><dd>{scan.library.errored}</dd></div>
|
||||
</dl>
|
||||
{:else}
|
||||
<p class="mt-2 text-sm text-text-muted">{scanInFlight ? 'Pending…' : 'Skipped.'}</p>
|
||||
{:else if stageState(scan, 'library') === 'pending'}
|
||||
<p class="mt-2 text-sm text-text-muted">Waiting for previous stage…</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- MBID backfill stage -->
|
||||
<div class="rounded border border-border bg-bg p-3">
|
||||
<div class="text-xs font-medium uppercase tracking-wide text-text-muted">MBID backfill</div>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<div class="text-xs font-medium uppercase tracking-wide text-text-muted">MBID backfill</div>
|
||||
<StageBadge state={stageState(scan, 'mbid_backfill')} />
|
||||
</div>
|
||||
{#if scan.mbid_backfill}
|
||||
<dl class="mt-2 space-y-0.5 text-sm">
|
||||
<div class="flex justify-between"><dt class="text-text-secondary">Processed</dt><dd>{scan.mbid_backfill.processed}</dd></div>
|
||||
@@ -370,36 +378,42 @@
|
||||
<div class="flex justify-between"><dt class="text-text-secondary">Skipped</dt><dd>{scan.mbid_backfill.skipped}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-text-secondary">Duplicates</dt><dd>{scan.mbid_backfill.duplicates ?? 0}</dd></div>
|
||||
</dl>
|
||||
{:else}
|
||||
<p class="mt-2 text-sm text-text-muted">{scanInFlight ? 'Pending…' : 'Skipped.'}</p>
|
||||
{:else if stageState(scan, 'mbid_backfill') === 'pending'}
|
||||
<p class="mt-2 text-sm text-text-muted">Waiting for previous stage…</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Cover enrichment stage -->
|
||||
<div class="rounded border border-border bg-bg p-3">
|
||||
<div class="text-xs font-medium uppercase tracking-wide text-text-muted">Cover enrichment</div>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<div class="text-xs font-medium uppercase tracking-wide text-text-muted">Cover enrichment</div>
|
||||
<StageBadge state={stageState(scan, 'cover_enrich')} />
|
||||
</div>
|
||||
{#if scan.cover_enrich}
|
||||
<dl class="mt-2 space-y-0.5 text-sm">
|
||||
<div class="flex justify-between"><dt class="text-text-secondary">Processed</dt><dd>{scan.cover_enrich.processed}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-text-secondary">Succeeded</dt><dd>{scan.cover_enrich.succeeded}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-text-secondary">Failed</dt><dd>{scan.cover_enrich.failed}</dd></div>
|
||||
</dl>
|
||||
{:else}
|
||||
<p class="mt-2 text-sm text-text-muted">{scanInFlight ? 'Pending…' : 'Skipped.'}</p>
|
||||
{:else if stageState(scan, 'cover_enrich') === 'pending'}
|
||||
<p class="mt-2 text-sm text-text-muted">Waiting for previous stage…</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Artist art enrichment stage -->
|
||||
<div class="rounded border border-border bg-bg p-3">
|
||||
<div class="text-xs font-medium uppercase tracking-wide text-text-muted">Artist art</div>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<div class="text-xs font-medium uppercase tracking-wide text-text-muted">Artist art</div>
|
||||
<StageBadge state={stageState(scan, 'artist_art_enrich')} />
|
||||
</div>
|
||||
{#if scan.artist_art_enrich}
|
||||
<dl class="mt-2 space-y-0.5 text-sm">
|
||||
<div class="flex justify-between"><dt class="text-text-secondary">Processed</dt><dd>{scan.artist_art_enrich.processed}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-text-secondary">Succeeded</dt><dd>{scan.artist_art_enrich.succeeded}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-text-secondary">Failed</dt><dd>{scan.artist_art_enrich.failed}</dd></div>
|
||||
</dl>
|
||||
{:else}
|
||||
<p class="mt-2 text-sm text-text-muted">{scanInFlight ? 'Pending…' : 'Skipped.'}</p>
|
||||
{:else if stageState(scan, 'artist_art_enrich') === 'pending'}
|
||||
<p class="mt-2 text-sm text-text-muted">Waiting for previous stage…</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,12 +22,13 @@ vi.mock('$lib/api/admin', async () => {
|
||||
createAdminRequestsQuery: vi.fn(),
|
||||
createLidarrConfigQuery: vi.fn(),
|
||||
createAdminQuarantineQuery: vi.fn(),
|
||||
createScanStatusQuery: () =>
|
||||
createScanStatusQuery: vi.fn(() =>
|
||||
readable({
|
||||
data: { id: '', started_at: '', finished_at: null, in_flight: false },
|
||||
isPending: false,
|
||||
isError: false
|
||||
}),
|
||||
})
|
||||
),
|
||||
createCoverageQuery: () =>
|
||||
readable({
|
||||
data: undefined,
|
||||
@@ -91,6 +92,7 @@ import {
|
||||
createAdminRequestsQuery,
|
||||
createLidarrConfigQuery,
|
||||
createAdminQuarantineQuery,
|
||||
createScanStatusQuery,
|
||||
approveRequest,
|
||||
rejectRequest,
|
||||
resolveQuarantine,
|
||||
@@ -239,4 +241,25 @@ describe('admin Overview page', () => {
|
||||
'href', '/admin/requests'
|
||||
);
|
||||
});
|
||||
|
||||
test('Library scan card shows Processing badge when library tally is populated and in-flight', async () => {
|
||||
const { readable } = await import('svelte/store');
|
||||
(createScanStatusQuery as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
readable({
|
||||
data: {
|
||||
id: 'in-flight',
|
||||
started_at: '2026-05-06T11:00:00Z',
|
||||
finished_at: null,
|
||||
in_flight: true,
|
||||
library: { scanned: 100, added: 1, updated: 0, skipped: 99, errored: 0 }
|
||||
},
|
||||
isPending: false,
|
||||
isError: false
|
||||
})
|
||||
);
|
||||
setupOverview();
|
||||
userState.current = { id: 'admin', username: 'admin', is_admin: true };
|
||||
render(OverviewPage);
|
||||
expect(await screen.findByText('Processing')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user