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:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, `
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
12
web/src/routes/admin/discovery/queue/[id]/similar/+server.ts
Normal file
12
web/src/routes/admin/discovery/queue/[id]/similar/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user