feat: Reports, Duplicates and Suspect sources live under Collection, with their counts on its rail and a "To look at" line naming the ones waiting; notices link there (M504 #5625)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 5m31s
release / android (push) Successful in 6m5s
release / Build signed APK (releases and dev) (push) Successful in 6m21s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 32s
release / Verify release artifacts (tag releases only) (push) Skipped

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 14:35:59 -04:00
co-authored by Claude Opus 5.5
parent 12725e71b0
commit 38b2325d2d
17 changed files with 114 additions and 53 deletions
+6 -5
View File
@@ -1,8 +1,9 @@
import type { AdminQueue, AdminSummary } from '#lib/api/types.js';
// The queues that wait on an admin (#5605), in the order the Overview lists
// them. The rail and the Overview read the same list, so a queue that
// needs you is counted the same in both places.
// them. The rails and the Overview read the same list, so a queue that
// needs you is counted the same in every place. Requests live under Admin;
// the library's health queues under Collection (M504).
export type QueueKey = keyof AdminSummary;
export type QueueDef = {
@@ -17,9 +18,9 @@ export type QueueDef = {
export const QUEUES: QueueDef[] = [
{ key: 'requests', href: '/admin/requests', one: 'request to decide', many: 'requests to decide', none: 'requests' },
{ key: 'reports', href: '/admin/reports', one: 'broken track reported', many: 'broken tracks reported', none: 'reports' },
{ key: 'duplicates', href: '/admin/duplicates', one: 'set of duplicates to review', many: 'sets of duplicates to review', none: 'duplicates' },
{ key: 'suspect', href: '/admin/suspect-sources', one: 'track from a suspect source', many: 'tracks from suspect sources', none: 'suspect sources' },
{ key: 'reports', href: '/collection/reports', one: 'broken track reported', many: 'broken tracks reported', none: 'reports' },
{ key: 'duplicates', href: '/collection/duplicates', one: 'set of duplicates to review', many: 'sets of duplicates to review', none: 'duplicates' },
{ key: 'suspect', href: '/collection/suspect-sources', one: 'track from a suspect source', many: 'tracks from suspect sources', none: 'suspect sources' },
{ key: 'missing', href: '/admin/missing-files', one: 'missing file', many: 'missing files', none: 'missing files' }
];
@@ -1,14 +1,18 @@
<script lang="ts">
import SectionRail from '#lib/components/SectionRail.svelte';
import type { WantCounts } from '#lib/api/types.js';
import { countOf } from '#lib/admin/queues.js';
import type { AdminSummary, WantCounts } from '#lib/api/types.js';
// The Collection's sections, down the left (#5572): the catalogue of
// artists first, then the sections that show state. Activity carries a
// second, accented count when downloads are waiting on a person. Unmatched
// holds the library albums no MusicBrainz album matched (#5579). Upgrades
// holds the albums held but wanted for a better copy (#5524).
// holds the albums held but wanted for a better copy (#5524). Reports,
// Duplicates and Suspect sources are the library's health queues (M504):
// their counts come from the admin summary and are accented, as they wait
// on a person.
let { counts }: { counts?: WantCounts } = $props();
let { counts, summary }: { counts?: WantCounts; summary?: AdminSummary } = $props();
// An artist's page belongs to the catalogue.
const items = $derived([
@@ -19,7 +23,10 @@
{ href: '/collection/activity', label: 'Activity', count: counts?.activity, attention: counts?.attention },
{ href: '/collection/following', label: 'Following', count: counts?.following },
{ href: '/collection/blocked', label: 'Blocked', count: counts?.blocked },
{ href: '/collection/unmatched', label: 'Unmatched', count: counts?.unmatched }
{ href: '/collection/unmatched', label: 'Unmatched', count: counts?.unmatched },
{ href: '/collection/reports', label: 'Reports', attention: countOf(summary, 'reports') },
{ href: '/collection/duplicates', label: 'Duplicates', attention: countOf(summary, 'duplicates') },
{ href: '/collection/suspect-sources', label: 'Suspect sources', attention: countOf(summary, 'suspect') }
]);
</script>
+1 -3
View File
@@ -11,6 +11,7 @@
// the Collection are, so the sections sit in the same place everywhere
// and fit a phone. Each queue carries how many wait on you, from one
// summary read that is refreshed whenever you move between admin pages.
// The library's health queues are under Collection (M504).
const summaryStore = createAdminSummaryQuery();
const summary = $derived($summaryStore.data);
@@ -28,9 +29,6 @@
const items: RailItem[] = $derived([
{ href: '/admin', label: 'Overview', exact: true },
{ href: '/admin/requests', label: 'Requests', attention: countOf(summary, 'requests') },
{ href: '/admin/reports', label: 'Reports', attention: countOf(summary, 'reports') },
{ href: '/admin/duplicates', label: 'Duplicates', attention: countOf(summary, 'duplicates') },
{ href: '/admin/suspect-sources', label: 'Suspect sources', attention: countOf(summary, 'suspect') },
{ href: '/admin/missing-files', label: 'Missing files', attention: countOf(summary, 'missing') },
{ href: '/admin/library', label: 'Library' },
{ href: '/admin/acquisition', label: 'Acquisition' },
+10 -3
View File
@@ -116,6 +116,16 @@ describe('admin Overview page', () => {
expect(screen.getByTestId('all-clear').textContent).not.toMatch(/requests/);
});
// The library's health queues live under Collection (M504); the Overview
// still lists them, linking there.
test('a library queue that waits on you opens its Collection page', () => {
setupOverview(summary({ duplicates: { count: 5, since: ago(4) } }));
render(OverviewPage);
expect(screen.getByRole('link', { name: /5 sets of duplicates to review/i }).getAttribute('href')).toBe(
'/collection/duplicates'
);
});
test('a failed scan needs you too', async () => {
const { readable } = await import('svelte/store');
(createScanStatusQuery as ReturnType<typeof vi.fn>).mockReturnValueOnce(
@@ -158,9 +168,6 @@ describe('admin layout', () => {
expect(links).toEqual([
['/admin', 'Overview'],
['/admin/requests', 'Requests 2'],
['/admin/reports', 'Reports'],
['/admin/duplicates', 'Duplicates 5'],
['/admin/suspect-sources', 'Suspect sources'],
['/admin/missing-files', 'Missing files'],
['/admin/library', 'Library'],
['/admin/acquisition', 'Acquisition'],
@@ -1,9 +0,0 @@
import { redirect } from '@sveltejs/kit';
// Quarantine and Playback errors are one page now, Admin › Reports (#5606).
// The old address stays because notifications and the Android app link to
// it, and a link already sent should still land somewhere useful. A
// universal load, as the app is adapter-static with ssr off.
export const load = () => {
throw redirect(308, '/admin/reports');
};
-9
View File
@@ -1,9 +0,0 @@
import { redirect } from '@sveltejs/kit';
// Quarantine and Playback errors are one page now, Admin › Reports (#5606).
// The old address stays because notifications and the Android app link to
// it, and a link already sent should still land somewhere useful. A
// universal load, as the app is adapter-static with ssr off.
export const load = () => {
throw redirect(308, '/admin/reports');
};
+21 -1
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import { createWantArtistsQuery } from '#lib/api/wanted.js';
import { createAdminSummaryQuery } from '#lib/api/admin.js';
import { QUEUES, countOf, queueLabel } from '#lib/admin/queues.js';
import CollectionRail from '#lib/components/wanted/CollectionRail.svelte';
let { children } = $props<{ children: import('svelte').Snippet }>();
@@ -28,6 +30,15 @@
return parts.join(', ') + '. Nothing needs you.';
});
const needsYou = $derived(!!counts?.attention || !!counts?.unmatched);
// The library's health queues (M504) count from the admin summary, the
// same read Admin's Overview makes. The line under the verdict names the
// ones with something in them; each links to its page.
const summaryStore = createAdminSummaryQuery();
const summary = $derived($summaryStore.data);
const health = $derived(
QUEUES.filter((d) => d.href.startsWith('/collection/') && countOf(summary, d.key) > 0)
);
</script>
<div class="space-y-4">
@@ -50,12 +61,21 @@
{/if}
</p>
{/if}
{#if health.length > 0}
<p class="text-sm text-text-primary" data-testid="collection-health">
To look at:
{#each health as d, i (d.key)}<a href={d.href} class="text-accent-fg hover:underline"
>{queueLabel(d, countOf(summary, d.key))}</a
>{i < health.length - 1 ? ', ' : '.'}
{/each}
</p>
{/if}
</header>
<div class="md:grid md:grid-cols-[11rem_minmax(0,1fr)] md:gap-6">
<div class="mb-4 md:mb-0">
<div class="md:sticky md:top-4">
<CollectionRail {counts} />
<CollectionRail {counts} {summary} />
</div>
</div>
<div class="min-w-0">
+42 -3
View File
@@ -3,22 +3,31 @@ import { render, screen } from '@testing-library/svelte';
import { createRawSnippet } from 'svelte';
import { mockInfiniteQuery } from '../../test-utils/query';
import { pageUrlModule } from '#test-utils/mocks/appState.js';
import type { WantArtistsPage, WantCounts } from '#lib/api/types.js';
import { mockQuery } from '../../test-utils/query';
import type { AdminSummary, WantArtistsPage, WantCounts } from '#lib/api/types.js';
const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/collection') }));
vi.mock('$app/state', () => pageUrlModule(state));
vi.mock('#lib/api/wanted.js', () => ({ createWantArtistsQuery: vi.fn() }));
vi.mock('#lib/api/admin.js', () => ({ createAdminSummaryQuery: vi.fn() }));
import WantedLayout from './+layout.svelte';
import { createWantArtistsQuery } from '#lib/api/wanted.js';
import { createAdminSummaryQuery } from '#lib/api/admin.js';
const counts: WantCounts = { artists: 7, wanted: 5, missing: 1, held: 9, upgrades: 6, following: 2, activity: 3, attention: 0, blocked: 4, unmatched: 3 };
const children = createRawSnippet(() => ({ render: () => `<p data-testid="tab-body">body</p>` }));
afterEach(() => vi.clearAllMocks());
function setup(c: WantCounts = counts, path = '/collection') {
const none = { count: 0, since: null };
const clear: AdminSummary = { requests: none, reports: none, duplicates: none, suspect: none, missing: none };
function setup(c: WantCounts = counts, path = '/collection', summary: AdminSummary = clear) {
state.pageUrl = new URL(`http://localhost${path}`);
vi.mocked(createAdminSummaryQuery).mockReturnValue(
mockQuery({ data: summary }) as unknown as ReturnType<typeof createAdminSummaryQuery>
);
const page: WantArtistsPage = {
items: [],
total: 0,
@@ -47,7 +56,10 @@ describe('/collection layout', () => {
['/collection/activity', 'Activity 3'],
['/collection/following', 'Following 2'],
['/collection/blocked', 'Blocked 4'],
['/collection/unmatched', 'Unmatched 3']
['/collection/unmatched', 'Unmatched 3'],
['/collection/reports', 'Reports'],
['/collection/duplicates', 'Duplicates'],
['/collection/suspect-sources', 'Suspect sources']
]);
expect(screen.getByRole('link', { name: /activity/i })).toHaveAttribute('aria-current', 'page');
expect(screen.getByRole('link', { name: /^catalogue/i })).not.toHaveAttribute('aria-current');
@@ -76,6 +88,33 @@ describe('/collection layout', () => {
expect(screen.getByRole('link', { name: /see unmatched/i }).getAttribute('href')).toBe('/collection/unmatched');
});
// The library's health queues moved here from Admin (M504): their counts
// come from the admin summary, accented, and a line names the ones waiting.
test('reports, duplicates and suspect sources carry what waits on you, and a line links to each', () => {
setup(counts, '/collection', {
...clear,
reports: { count: 2, since: null },
duplicates: { count: 5, since: null },
missing: { count: 9, since: null }
});
const nav = screen.getByRole('navigation', { name: /collection sections/i });
const dup = Array.from(nav.querySelectorAll('a')).find((a) => a.getAttribute('href') === '/collection/duplicates')!;
expect(dup.textContent?.replace(/\s+/g, ' ').trim()).toBe('Duplicates 5');
const health = screen.getByTestId('collection-health');
expect(health.textContent?.replace(/\s+/g, ' ').trim()).toBe(
'To look at: 2 broken tracks reported, 5 sets of duplicates to review.'
);
expect(Array.from(health.querySelectorAll('a')).map((a) => a.getAttribute('href'))).toEqual([
'/collection/reports',
'/collection/duplicates'
]);
});
test('with nothing waiting there is no health line', () => {
setup();
expect(screen.queryByTestId('collection-health')).toBeNull();
});
test("an artist's page sits under Catalogue", () => {
setup(counts, '/collection/artist/blur');
expect(screen.getByRole('link', { name: /^catalogue/i })).toHaveAttribute('aria-current', 'page');
@@ -187,7 +187,7 @@
}
</script>
<svelte:head><title>{pageTitle('Admin · Duplicates')}</title></svelte:head>
<svelte:head><title>{pageTitle('Collection · Duplicates')}</title></svelte:head>
<div class="space-y-6">
<header class="space-y-1">
@@ -147,7 +147,7 @@
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
</script>
<svelte:head><title>{pageTitle('Admin · Reports')}</title></svelte:head>
<svelte:head><title>{pageTitle('Collection · Reports')}</title></svelte:head>
<div class="space-y-4">
<header class="space-y-1">
@@ -63,7 +63,7 @@ function setup(q: AdminQuarantineRow[], e: AdminPlaybackError[], opts: { error?:
return render(Page);
}
describe('/admin/reports', () => {
describe('/collection/reports', () => {
test('a flagged track and a track that failed to play are one list, one row per track, newest first', () => {
setup([flagged], [failure('e1', 't1', 'Roygbiv', 1), failure('e2', 't2', 'Intro', 9)]);
const rows = screen.getAllByTestId('report-row');
@@ -82,7 +82,7 @@
}
</script>
<svelte:head><title>{pageTitle('Admin · Suspect sources')}</title></svelte:head>
<svelte:head><title>{pageTitle('Collection · Suspect sources')}</title></svelte:head>
<div class="space-y-6">
<header class="space-y-1">