Merge branch 'feat/admin-queue-pagination-and-similar' — MR 6 queue UX

Queue endpoint returns {data, total, limit, offset}; admin UI exposes
prev/next + page-size + Showing X-Y of Z. Per-row Similar button
fetches MR 5's /queue/:id/similar via a SvelteKit proxy and renders
matches inline. Essential for reviewing the 1000+ row queue post-fix.
This commit is contained in:
2026-04-19 00:14:52 +02:00
8 changed files with 251 additions and 13 deletions

View File

@@ -174,13 +174,18 @@ func (h *Handler) ListQueue(c *gin.Context) {
if offset < 0 {
offset = 0
}
rows, err := h.service.ListPendingQueue(c.Request.Context(), limit, offset)
rows, total, err := h.service.ListPendingQueuePaged(c.Request.Context(), limit, offset)
if err != nil {
apiErr := apierror.Internal("list queue failed")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusOK, gin.H{"data": rows})
c.JSON(http.StatusOK, gin.H{
"data": rows,
"total": total,
"limit": limit,
"offset": offset,
})
}
func (h *Handler) Accept(c *gin.Context) {

View File

@@ -17,6 +17,7 @@ type mockRepo struct {
insertDiscFn func(ctx context.Context, d DiscoveredMarket) (uuid.UUID, error)
isRejectedFn func(ctx context.Context, nameNormalized, stadt string, year int) (bool, error)
queuePendingFn func(ctx context.Context, nameNormalized, stadt string, startDatum *time.Time) (bool, error)
countQueueFn func(ctx context.Context, status string) (int, error)
getDiscoveredFn func(ctx context.Context, id uuid.UUID) (DiscoveredMarket, error)
beginTxFn func(ctx context.Context) (pgx.Tx, error)
markAcceptedFn func(ctx context.Context, tx pgx.Tx, id, eid, r uuid.UUID) error
@@ -46,6 +47,12 @@ func (m *mockRepo) QueueHasPending(ctx context.Context, n, s string, sd *time.Ti
func (m *mockRepo) ListQueue(ctx context.Context, status string, l, o int) ([]DiscoveredMarket, error) {
return nil, nil
}
func (m *mockRepo) CountQueue(ctx context.Context, status string) (int, error) {
if m.countQueueFn != nil {
return m.countQueueFn(ctx, status)
}
return 0, nil
}
func (m *mockRepo) GetDiscovered(ctx context.Context, id uuid.UUID) (DiscoveredMarket, error) {
if m.getDiscoveredFn != nil {
return m.getDiscoveredFn(ctx, id)

View File

@@ -18,6 +18,7 @@ type Repository interface {
IsRejected(ctx context.Context, nameNormalized, stadt string, year int) (bool, error)
QueueHasPending(ctx context.Context, nameNormalized, stadt string, startDatum *time.Time) (bool, error)
ListQueue(ctx context.Context, status string, limit, offset int) ([]DiscoveredMarket, error)
CountQueue(ctx context.Context, status string) (int, error)
GetDiscovered(ctx context.Context, id uuid.UUID) (DiscoveredMarket, error)
MarkAccepted(ctx context.Context, tx pgx.Tx, id, editionID, reviewer uuid.UUID) error
MarkRejected(ctx context.Context, tx pgx.Tx, id uuid.UUID, reviewer uuid.UUID) error
@@ -140,6 +141,13 @@ LIMIT $2 OFFSET $3`, status, limit, offset)
return out, rows.Err()
}
func (r *pgRepository) CountQueue(ctx context.Context, status string) (int, error) {
var n int
err := r.pool.QueryRow(ctx,
`SELECT count(*) FROM discovered_markets WHERE status = $1`, status).Scan(&n)
return n, err
}
func (r *pgRepository) GetDiscovered(ctx context.Context, id uuid.UUID) (DiscoveredMarket, error) {
var d DiscoveredMarket
err := r.pool.QueryRow(ctx, `

View File

@@ -384,9 +384,18 @@ func landToISO(land string) string {
return ""
}
// ListPendingQueue exposes queue listing for the handler layer.
func (s *Service) ListPendingQueue(ctx context.Context, limit, offset int) ([]DiscoveredMarket, error) {
return s.repo.ListQueue(ctx, StatusPending, limit, offset)
// ListPendingQueuePaged returns pending queue rows with the total count so
// the admin UI can paginate. limit/offset are passed through to the repo.
func (s *Service) ListPendingQueuePaged(ctx context.Context, limit, offset int) ([]DiscoveredMarket, int, error) {
rows, err := s.repo.ListQueue(ctx, StatusPending, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("list queue: %w", err)
}
total, err := s.repo.CountQueue(ctx, StatusPending)
if err != nil {
return nil, 0, fmt.Errorf("count queue: %w", err)
}
return rows, total, nil
}
// UpdatePending patches editable fields on a pending queue entry. When MarktName

View File

@@ -322,6 +322,24 @@ func TestServiceCrawlDetachesInsertContextFromRequestCtx(t *testing.T) {
}
}
func TestListPendingQueuePaged_ReturnsBothRowsAndTotal(t *testing.T) {
// mockRepo.ListQueue returns nil,nil by default (sufficient to verify total flows through).
m := &mockRepo{
countQueueFn: func(_ context.Context, _ string) (int, error) { return 42, nil },
}
svc := NewService(m, nil, noopLinkVerifier{}, noopMarketCreator{})
rows, total, err := svc.ListPendingQueuePaged(context.Background(), 50, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if total != 42 {
t.Errorf("total = %d; want 42", total)
}
if rows != nil {
t.Errorf("rows = %v; want nil (mock returns nil by default)", rows)
}
}
func TestServiceCrawlMultiSourceHighKonfidenz(t *testing.T) {
repo := newMockRepo()
start := mustParseDate(t, "2026-05-01")

View File

@@ -36,17 +36,35 @@ type Stats = {
recent_errors: BucketError[] | null;
};
const VALID_LIMITS = [25, 50, 100, 200] as const;
type ValidLimit = (typeof VALID_LIMITS)[number];
function parseLimit(raw: string | null): ValidLimit {
const n = Number(raw ?? 50);
return (VALID_LIMITS as readonly number[]).includes(n) ? (n as ValidLimit) : 50;
}
type QueueListBody = {
data: DiscoveredMarket[];
total: number;
limit: number;
offset: number;
};
export const load: PageServerLoad = async ({ cookies, url }) => {
const limit = Number(url.searchParams.get('limit') ?? 50);
const offset = Number(url.searchParams.get('offset') ?? 0);
const limit = parseLimit(url.searchParams.get('limit'));
const page = Math.max(1, Number(url.searchParams.get('page') ?? 1));
const offset = (page - 1) * limit;
const [queueRes, statsRes] = await Promise.all([
serverFetch<DiscoveredMarket[]>(
`/admin/discovery/queue?limit=${limit}&offset=${offset}`,
cookies
),
serverFetch<QueueListBody>(`/admin/discovery/queue?limit=${limit}&offset=${offset}`, cookies),
serverFetch<Stats>(`/admin/discovery/stats`, cookies)
]);
return { queue: queueRes.data, stats: statsRes.data, limit, offset };
// serverFetch casts the full JSON body as ApiResponse<T>; the actual response
// shape is { data: [...], total: N, limit: N, offset: N } so queueRes.data
// is the QueueListBody with all fields.
const body = queueRes.data;
return { queue: body.data, total: body.total, stats: statsRes.data, limit, offset, page };
};
export const actions: Actions = {

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
let { data, form } = $props();
@@ -42,6 +43,58 @@
expandedId = expandedId === id ? null : id;
}
// Similar panel — one open at a time.
type SimilarEntry = {
entry: {
id: string;
markt_name: string;
stadt: string;
start_datum: string | null;
konfidenz: string;
};
score: number;
};
let similarOpenId: string | null = $state(null);
let similarLoading = $state(false);
let similarEntries = $state<SimilarEntry[]>([]);
async function toggleSimilar(id: string) {
if (similarOpenId === id) {
similarOpenId = null;
return;
}
similarOpenId = id;
similarLoading = true;
similarEntries = [];
try {
const res = await fetch(`/admin/discovery/queue/${id}/similar`);
if (!res.ok) {
similarEntries = [];
return;
}
const body = await res.json();
similarEntries = body.data ?? [];
} catch {
similarEntries = [];
} finally {
similarLoading = false;
}
}
// Pagination helpers.
const totalPages = $derived(Math.ceil((data.total ?? 0) / data.limit));
function navigatePage(page: number) {
goto(`?page=${page}&limit=${data.limit}`);
}
function navigateLimit(newLimit: number) {
goto(`?page=1&limit=${newLimit}`);
}
const showingFrom = $derived(data.offset + 1);
const showingTo = $derived(Math.min(data.offset + data.limit, data.total ?? 0));
// 'YYYY-MM-DDTHH:mm:ssZ' → 'YYYY-MM-DD' for <input type="date">
function dateInputValue(iso: string | null): string {
return iso ? iso.slice(0, 10) : '';
@@ -341,7 +394,11 @@
<h2 class="mt-8 text-lg font-semibold">Queue</h2>
<p class="mt-1 text-sm text-stone-500">
{queue.length} pending · showing from offset {data.offset}
{#if (data.total ?? 0) > 0}
Showing {showingFrom}{showingTo} of {data.total}
{:else}
Keine Einträge in der Warteschlange.
{/if}
</p>
{#if queue.length === 0}
@@ -449,8 +506,73 @@
Reject
</button>
</form>
<button
type="button"
onclick={() => toggleSimilar(row.id)}
class="ml-1 rounded px-2 py-1 text-xs {similarOpenId === row.id
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:text-indigo-300'
: 'bg-stone-100 text-stone-600 hover:bg-stone-200 dark:bg-stone-800 dark:text-stone-300 dark:hover:bg-stone-700'}"
>
Similar
</button>
</td>
</tr>
{#if similarOpenId === row.id}
<tr
class="border-b border-stone-100 bg-indigo-50/50 dark:border-stone-800 dark:bg-indigo-950/20"
>
<td></td>
<td colspan="8" class="py-3 pr-4">
<div
class="text-xs font-medium tracking-wider text-indigo-700 uppercase dark:text-indigo-400"
>
Ähnliche Einträge
</div>
{#if similarLoading}
<p class="mt-2 text-xs text-stone-400">Laden…</p>
{:else if similarEntries.length === 0}
<p class="mt-2 text-xs text-stone-400">Keine ähnlichen Einträge gefunden.</p>
{:else}
<table class="mt-2 w-full text-left text-xs">
<thead>
<tr
class="border-b border-indigo-200 text-indigo-600 dark:border-indigo-800 dark:text-indigo-400"
>
<th class="pr-4 pb-1 font-medium">Score</th>
<th class="pr-4 pb-1 font-medium">Markt</th>
<th class="pr-4 pb-1 font-medium">Stadt</th>
<th class="pr-4 pb-1 font-medium">Datum</th>
<th class="pb-1 font-medium">Konfidenz</th>
</tr>
</thead>
<tbody>
{#each similarEntries as m}
<tr class="border-b border-indigo-100 dark:border-indigo-900">
<td class="py-1 pr-4 text-stone-500 tabular-nums"
>{(m.score * 100).toFixed(0)}%</td
>
<td class="py-1 pr-4 font-medium">{m.entry.markt_name}</td>
<td class="py-1 pr-4">{m.entry.stadt}</td>
<td class="py-1 pr-4 whitespace-nowrap"
>{fmtDate(m.entry.start_datum)}</td
>
<td class="py-1">
<span
class="inline-block rounded px-1.5 py-0.5 {konfidenzClass(
m.entry.konfidenz
)}"
>
{m.entry.konfidenz || '—'}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</td>
</tr>
{/if}
{#if expandedId === row.id}
<tr
class="border-b border-stone-100 bg-stone-50 dark:border-stone-800 dark:bg-stone-900/50"
@@ -595,6 +717,45 @@
{/each}
</tbody>
</table>
{#if (data.total ?? 0) > data.limit}
<div class="mt-4 flex flex-wrap items-center gap-3">
<button
type="button"
disabled={data.page <= 1}
onclick={() => navigatePage(data.page - 1)}
class="rounded border border-stone-300 bg-white px-3 py-1.5 text-sm text-stone-700 hover:bg-stone-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-stone-700 dark:bg-stone-900 dark:text-stone-300 dark:hover:bg-stone-800"
>
← Zurück
</button>
<span class="text-sm text-stone-500">
Seite {data.page} von {totalPages}
</span>
<button
type="button"
disabled={data.offset + data.limit >= (data.total ?? 0)}
onclick={() => navigatePage(data.page + 1)}
class="rounded border border-stone-300 bg-white px-3 py-1.5 text-sm text-stone-700 hover:bg-stone-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-stone-700 dark:bg-stone-900 dark:text-stone-300 dark:hover:bg-stone-800"
>
Weiter →
</button>
<label class="ml-auto flex items-center gap-2 text-sm text-stone-500">
Pro Seite
<select
value={data.limit}
onchange={(e) => navigateLimit(Number((e.target as HTMLSelectElement).value))}
class="rounded border border-stone-300 bg-white px-2 py-1 text-sm text-stone-700 dark:border-stone-700 dark:bg-stone-900 dark:text-stone-300"
>
{#each [25, 50, 100, 200] as n}
<option value={n}>{n}</option>
{/each}
</select>
</label>
</div>
{/if}
</div>
{/if}
</div>

View File

@@ -0,0 +1,12 @@
import { json } from '@sveltejs/kit';
import { serverFetch } from '$lib/api/client.server.js';
import type { RequestHandler } from './$types.js';
export const GET: RequestHandler = async ({ cookies, params }) => {
try {
const res = await serverFetch<unknown>(`/admin/discovery/queue/${params.id}/similar`, cookies);
return json(res.data);
} catch {
return json({ error: 'Failed to fetch similar entries' }, { status: 502 });
}
};