feat(web): polish discovery admin page and drawer

Discovery drawer
- Wrap each section in a rounded card so boundaries are visible without
  parsing the uppercase headers.
- Header: N Quellen and enrichment_status become consistent pills,
  matching the existing konfidenz pill treatment.
- Enrichment: replace the inline "(llm)"/"(crawl)" trailing text with a
  color-coded badge on the label side (purple = llm, sky = crawl).
- Empty enrichment state now tells the operator how to trigger it.
- Audit timestamp uses a local-time helper so the displayed time
  matches the browser timezone (was UTC-as-local).
- Quellen list: prefix each URL with its hostname for scannability;
  long URLs truncated with full URL in the title attribute.

ContributionsPanel
- Amber border/background now only on conflict rows; every row
  previously got border-amber-100 unconditionally, which diluted the
  conflict signal. Rang 1 badge flipped to emerald so it reads as a
  positive "winner" marker, not a warning.

Discovery page
- Remove dead dateInputValue() function and the stale
  a11y_click_events_have_key_events suppression — both flagged by
  eslint after earlier refactors.
- Render crawl/enrich timestamps in the browser's local timezone via a
  new fmtLocalStamp helper; the previous .slice(0,16).replace('T',' ')
  treated the ISO UTC string as if it were local time.
This commit is contained in:
2026-04-24 13:37:39 +02:00
parent fcebc37bcb
commit 2fdd8e8222
3 changed files with 241 additions and 163 deletions

View File

@@ -111,9 +111,7 @@
// Scroll into view so the highlighted row stays visible during
// keyboard-driven scanning through a long list.
requestAnimationFrame(() => {
document
.querySelector(`[data-row-id="${selectedId}"]`)
?.scrollIntoView({ block: 'nearest' });
document.querySelector(`[data-row-id="${selectedId}"]`)?.scrollIntoView({ block: 'nearest' });
});
}
@@ -269,11 +267,6 @@
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) : '';
}
// 'YYYY-MM-DDTHH:mm:ssZ' → 'DD.MM.YYYY' (German) for display.
function fmtDate(iso: string | null): string {
if (!iso) return '';
@@ -289,6 +282,13 @@
return s || e || '';
}
// Backend emits UTC; render in the browser's local timezone.
function fmtLocalStamp(iso: string): string {
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function konfidenzClass(k: string): string {
switch (k) {
case 'hoch':
@@ -578,9 +578,7 @@
<div class="flex items-baseline justify-between">
<h2 class="font-semibold">Crawl-Ergebnis</h2>
<span class="font-mono text-xs text-stone-500"
>{(s.duration_ms / 1000).toFixed(1)} s · {s.started_at
.slice(0, 16)
.replace('T', ' ')}</span
>{(s.duration_ms / 1000).toFixed(1)} s · {fmtLocalStamp(s.started_at)}</span
>
</div>
@@ -671,9 +669,7 @@
<div class="flex items-baseline justify-between">
<h2 class="font-semibold">Crawl-Enrich-Ergebnis</h2>
<span class="font-mono text-xs text-stone-500"
>{(es.duration_ms / 1000).toFixed(1)} s · {es.started_at
.slice(0, 16)
.replace('T', ' ')}</span
>{(es.duration_ms / 1000).toFixed(1)} s · {fmtLocalStamp(es.started_at)}</span
>
</div>
<div class="mt-3 grid grid-cols-3 gap-2">
@@ -817,7 +813,6 @@
</thead>
<tbody>
{#each queue as row (row.id)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<tr
data-row-id={row.id}
class="cursor-pointer border-b border-stone-100 align-top hover:bg-stone-50 dark:border-stone-800 dark:hover:bg-stone-900/50 {drawerId ===
@@ -981,7 +976,9 @@
row={openRow}
onClose={closeDrawer}
llmEnriching={openRow ? llmEnrichingIds.has(openRow.id) : false}
llmEnrichError={openRow && form?.llmEnrichErrorId === openRow.id ? (form.llmEnrichError ?? '') : ''}
llmEnrichError={openRow && form?.llmEnrichErrorId === openRow.id
? (form.llmEnrichError ?? '')
: ''}
/>
<!-- Keyboard shortcut help modal. Triggered by '?'. -->

View File

@@ -44,14 +44,12 @@
{ label: 'Veranstalter', key: 'organizer' }
];
// Returns the display value for a contribution field.
function cellValue(c: SourceContribution, field: FieldDef): string {
const raw = c[field.key] as string | null | undefined;
if (field.format) return field.format(raw);
return raw ?? '';
}
// A field has a conflict if at least two sources have non-empty, different values.
function hasConflict(field: FieldDef): boolean {
const values = contributions.map((c) => cellValue(c, field)).filter((v) => v !== '');
if (values.length < 2) return false;
@@ -63,26 +61,38 @@
<p class="mt-2 text-xs text-stone-400">Keine Quelldaten vorhanden.</p>
{:else}
<div class="mt-2 overflow-x-auto">
<p class="mb-2 text-xs text-stone-500">
Rang 1 = höchste Priorität · Gewinnende Quelle:
<span class="font-mono font-medium text-stone-700 dark:text-stone-300"
>{contributions[0].source_name}</span
>
· Amber = abweichende Werte zwischen Quellen
</p>
<div class="mb-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
<span class="inline-flex items-center gap-1">
<span
class="inline-block h-2 w-2 rounded-full bg-emerald-500 dark:bg-emerald-400"
aria-hidden="true"
></span>
Gewinnende Quelle:
<span class="font-mono font-medium text-stone-700 dark:text-stone-300"
>{contributions[0].source_name}</span
>
</span>
<span class="inline-flex items-center gap-1">
<span
class="inline-block h-2 w-2 rounded-full bg-amber-500 dark:bg-amber-400"
aria-hidden="true"
></span>
Abweichende Werte zwischen Quellen
</span>
</div>
<table class="w-full text-left text-xs">
<thead>
<tr
class="border-b border-amber-200 text-amber-700 dark:border-amber-800 dark:text-amber-400"
class="border-b border-stone-200 text-stone-500 dark:border-stone-700 dark:text-stone-400"
>
<th class="pr-4 pb-1 font-medium">Feld</th>
{#each contributions as c, i}
<th class="pr-4 pb-1 font-medium">
<span class="font-mono">{c.source_name}</span>
<span class="font-mono text-stone-700 dark:text-stone-200">{c.source_name}</span>
{#if i === 0}
<span
class="ml-1 rounded bg-amber-100 px-1 text-[10px] text-amber-700 dark:bg-amber-900/50 dark:text-amber-300"
>Rang 1</span
class="ml-1 rounded bg-emerald-100 px-1 text-[10px] font-medium text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300"
title="Höchste Priorität — liefert die Gewinnerwerte">Rang 1</span
>
{/if}
</th>
@@ -93,18 +103,18 @@
{#each fields as field}
{@const conflict = hasConflict(field)}
<tr
class="border-b border-amber-100 dark:border-amber-900 {conflict
? 'bg-amber-50 dark:bg-amber-950/30'
: ''}"
class={conflict
? 'border-b border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30'
: 'border-b border-stone-100 dark:border-stone-800'}
>
<td
class="py-1 pr-4 font-medium text-stone-500 dark:text-stone-400 {conflict
class="py-1 pr-4 font-medium {conflict
? 'text-amber-700 dark:text-amber-400'
: ''}"
: 'text-stone-500 dark:text-stone-400'}"
>
{field.label}
{#if conflict}
<span title="Konflikt zwischen Quellen"></span>
<span title="Konflikt zwischen Quellen" aria-hidden="true"></span>
{/if}
</td>
{#each contributions as c}

View File

@@ -177,6 +177,12 @@
const [y, m, day] = d.split('-');
return y && m && day ? `${day}.${m}.${y}` : d;
}
// Backend emits UTC; render in the browser's local timezone.
function fmtLocalStamp(iso: string): string {
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function konfidenzClass(k: string): string {
switch (k) {
case 'hoch':
@@ -189,8 +195,58 @@
return 'bg-stone-100 text-stone-600 dark:bg-stone-800 dark:text-stone-400';
}
}
function provenanceClass(src: string | undefined): string {
if (src === 'llm') {
return 'bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300';
}
if (src === 'crawl') {
return 'bg-sky-100 text-sky-700 dark:bg-sky-900/50 dark:text-sky-300';
}
return 'bg-stone-100 text-stone-500 dark:bg-stone-800 dark:text-stone-400';
}
function hostnameOf(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return url;
}
}
function enrichmentStatusClass(s: string): string {
if (s === 'done')
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300';
if (s === 'failed') return 'bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300';
return 'bg-stone-100 text-stone-600 dark:bg-stone-800 dark:text-stone-300';
}
// Truthy iff the row has at least one enrichment value worth rendering.
const hasEnrichment = $derived.by(() => {
const en = row?.enrichment;
if (!en) return false;
return !!(
en.plz ||
en.venue ||
en.organizer ||
en.category ||
en.opening_hours ||
en.description ||
(en.lat != null && en.lng != null)
);
});
</script>
{#snippet sourceBadge(src: string | undefined)}
{#if src}
<span
class="inline-flex items-center rounded px-1 text-[9px] font-medium tracking-wide uppercase {provenanceClass(
src
)}"
title="Datenquelle: {src}"
>
{src}
</span>
{/if}
{/snippet}
{#if row}
<!-- Backdrop: clicking outside closes. Keyboard events handled by parent. -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
@@ -203,7 +259,7 @@
></div>
<aside
class="fixed top-0 right-0 bottom-0 z-50 w-full overflow-y-auto border-l border-stone-200 bg-white shadow-xl sm:w-2/3 dark:border-stone-700 dark:bg-stone-900"
class="fixed top-0 right-0 bottom-0 z-50 flex w-full flex-col overflow-y-auto border-l border-stone-200 bg-stone-50 shadow-xl sm:w-2/3 dark:border-stone-700 dark:bg-stone-950"
aria-label="Marktdetails"
>
<!-- Header -->
@@ -212,18 +268,30 @@
>
<div class="min-w-0 flex-1">
<h2 class="truncate text-lg font-semibold">{row.markt_name}</h2>
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs text-stone-500">
<span>{row.stadt}</span>
<div class="mt-1.5 flex flex-wrap items-center gap-1.5 text-xs">
<span class="text-stone-500 dark:text-stone-400">{row.stadt}</span>
{#if row.bundesland}
<span>· {row.bundesland}</span>
<span class="text-stone-400 dark:text-stone-500">·</span>
<span class="text-stone-500 dark:text-stone-400">{row.bundesland}</span>
{/if}
<span
class="rounded px-1.5 py-0.5 {konfidenzClass(row.konfidenz)}"
class="rounded px-1.5 py-0.5 font-medium {konfidenzClass(row.konfidenz)}"
title="Konfidenz"
>
{row.konfidenz || '—'}
</span>
<span class="font-mono">{row.sources.length} Quelle{row.sources.length === 1 ? '' : 'n'}</span>
<span
class="rounded bg-stone-100 px-1.5 py-0.5 font-mono font-medium text-stone-600 dark:bg-stone-800 dark:text-stone-300"
title="Anzahl Quellen"
>
{row.sources.length} Quelle{row.sources.length === 1 ? '' : 'n'}
</span>
<span
class="rounded px-1.5 py-0.5 font-medium {enrichmentStatusClass(row.enrichment_status)}"
title="Enrichment-Status"
>
{row.enrichment_status}
</span>
</div>
</div>
<div class="flex items-center gap-2">
@@ -231,7 +299,7 @@
<input type="hidden" name="id" value={row.id} />
<button
type="submit"
class="rounded bg-emerald-600 px-3 py-1.5 text-xs text-white hover:bg-emerald-700"
class="rounded bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-700"
>
Accept
</button>
@@ -240,7 +308,7 @@
<input type="hidden" name="id" value={row.id} />
<button
type="submit"
class="rounded bg-stone-200 px-3 py-1.5 text-xs text-stone-700 hover:bg-stone-300 dark:bg-stone-700 dark:text-stone-200 dark:hover:bg-stone-600"
class="rounded bg-stone-200 px-3 py-1.5 text-xs font-medium text-stone-700 hover:bg-stone-300 dark:bg-stone-700 dark:text-stone-200 dark:hover:bg-stone-600"
>
Reject
</button>
@@ -248,7 +316,7 @@
<button
type="button"
onclick={onClose}
class="rounded px-2 py-1.5 text-stone-500 hover:bg-stone-100 hover:text-stone-800 dark:hover:bg-stone-800 dark:hover:text-stone-100"
class="ml-1 rounded px-2 py-1.5 text-stone-500 hover:bg-stone-100 hover:text-stone-800 dark:hover:bg-stone-800 dark:hover:text-stone-100"
aria-label="Drawer schließen"
>
@@ -256,15 +324,17 @@
</div>
</div>
<div class="space-y-6 px-6 py-6">
<div class="flex-1 space-y-4 px-6 py-6">
<!-- Identity / editable form -->
<section>
<section
class="rounded-lg border border-stone-200 bg-white p-5 dark:border-stone-700 dark:bg-stone-900"
>
<h3
class="text-xs font-medium tracking-wider text-stone-500 uppercase dark:text-stone-400"
class="mb-3 text-xs font-semibold tracking-wider text-stone-500 uppercase dark:text-stone-400"
>
Identität
</h3>
<form method="POST" action="?/update" use:enhance class="mt-2 space-y-3 text-sm">
<form method="POST" action="?/update" use:enhance class="space-y-3 text-sm">
<input type="hidden" name="id" value={row.id} />
<div>
<label class="block text-xs text-stone-500" for="dd-name-{row.id}">Markt</label>
@@ -335,7 +405,7 @@
<div class="pt-1">
<button
type="submit"
class="rounded bg-blue-600 px-3 py-1.5 text-xs text-white hover:bg-blue-700"
class="rounded bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700"
>
Speichern
</button>
@@ -344,29 +414,21 @@
</section>
<!-- Enrichment payload -->
<section>
<div class="flex items-center justify-between">
<section
class="rounded-lg border border-stone-200 bg-white p-5 dark:border-stone-700 dark:bg-stone-900"
>
<div class="mb-3 flex items-center justify-between">
<h3
class="text-xs font-medium tracking-wider text-stone-500 uppercase dark:text-stone-400"
class="text-xs font-semibold tracking-wider text-stone-500 uppercase dark:text-stone-400"
>
Enrichment
<span
class="ml-1 inline-block rounded px-1.5 py-0.5 text-[10px] {row.enrichment_status ===
'done'
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300'
: row.enrichment_status === 'failed'
? 'bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300'
: 'bg-stone-100 text-stone-600 dark:bg-stone-800 dark:text-stone-300'}"
>
{row.enrichment_status}
</span>
</h3>
<form method="POST" action="?/llmEnrich" use:enhance class="inline">
<input type="hidden" name="id" value={row.id} />
<button
type="submit"
disabled={llmEnriching}
class="rounded bg-purple-100 px-2 py-1 text-xs text-purple-700 hover:bg-purple-200 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-purple-900/50 dark:text-purple-300 dark:hover:bg-purple-900"
class="rounded bg-purple-100 px-2.5 py-1 text-xs font-medium text-purple-700 hover:bg-purple-200 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-purple-900/50 dark:text-purple-300 dark:hover:bg-purple-900"
>
{llmEnriching ? 'AI…' : 'AI enrich'}
</button>
@@ -374,99 +436,99 @@
</div>
{#if llmEnrichError}
<p
class="mt-2 rounded border border-red-300 bg-red-50 px-2 py-1 text-xs text-red-700 dark:border-red-700 dark:bg-red-950 dark:text-red-300"
class="mb-3 rounded border border-red-300 bg-red-50 px-2 py-1 text-xs text-red-700 dark:border-red-700 dark:bg-red-950 dark:text-red-300"
>
{llmEnrichError}
</p>
{/if}
<dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
{#if row.enrichment.plz}
<dt class="text-stone-500">PLZ</dt>
<dd>
{row.enrichment.plz}
<span class="ml-1 text-[10px] text-stone-400"
>({row.enrichment.sources?.plz ?? '—'})</span
>
</dd>
{/if}
{#if row.enrichment.venue}
<dt class="text-stone-500">Venue</dt>
<dd>
{row.enrichment.venue}
<span class="ml-1 text-[10px] text-stone-400"
>({row.enrichment.sources?.venue ?? '—'})</span
>
</dd>
{/if}
{#if row.enrichment.organizer}
<dt class="text-stone-500">Organizer</dt>
<dd>
{row.enrichment.organizer}
<span class="ml-1 text-[10px] text-stone-400"
>({row.enrichment.sources?.organizer ?? '—'})</span
>
</dd>
{/if}
{#if row.enrichment.lat != null && row.enrichment.lng != null}
<dt class="text-stone-500">Lat/Lng</dt>
<dd class="font-mono">
{row.enrichment.lat.toFixed(4)}, {row.enrichment.lng.toFixed(4)}
<span class="ml-1 text-[10px] text-stone-400"
>({row.enrichment.sources?.lat ?? '—'})</span
>
</dd>
{/if}
{#if row.enrichment.category}
<dt class="text-stone-500">Category</dt>
<dd>
{row.enrichment.category}
<span class="ml-1 text-[10px] text-stone-400"
>({row.enrichment.sources?.category ?? '—'})</span
>
</dd>
{/if}
{#if row.enrichment.opening_hours}
<dt class="text-stone-500">Öffnungszeiten</dt>
<dd>
{row.enrichment.opening_hours}
<span class="ml-1 text-[10px] text-stone-400"
>({row.enrichment.sources?.opening_hours ?? '—'})</span
>
</dd>
{/if}
{#if row.enrichment.description}
<dt class="text-stone-500">Beschreibung</dt>
<dd class="text-stone-600 dark:text-stone-300">
{row.enrichment.description}
<span class="ml-1 text-[10px] text-stone-400"
>({row.enrichment.sources?.description ?? '—'})</span
>
</dd>
{/if}
</dl>
{#if !row.enrichment.plz && !row.enrichment.venue && !row.enrichment.organizer && !row.enrichment.category}
<p class="mt-2 text-xs text-stone-400">
Noch keine Enrichment-Daten. Crawl-Enrich oder AI enrich triggern.
{#if hasEnrichment}
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-xs">
{#if row.enrichment.plz}
<dt class="flex items-center gap-1.5 text-stone-500">
{@render sourceBadge(row.enrichment.sources?.plz)}
<span>PLZ</span>
</dt>
<dd class="text-stone-700 dark:text-stone-200">{row.enrichment.plz}</dd>
{/if}
{#if row.enrichment.venue}
<dt class="flex items-center gap-1.5 text-stone-500">
{@render sourceBadge(row.enrichment.sources?.venue)}
<span>Venue</span>
</dt>
<dd class="text-stone-700 dark:text-stone-200">{row.enrichment.venue}</dd>
{/if}
{#if row.enrichment.organizer}
<dt class="flex items-center gap-1.5 text-stone-500">
{@render sourceBadge(row.enrichment.sources?.organizer)}
<span>Organizer</span>
</dt>
<dd class="text-stone-700 dark:text-stone-200">{row.enrichment.organizer}</dd>
{/if}
{#if row.enrichment.lat != null && row.enrichment.lng != null}
<dt class="flex items-center gap-1.5 text-stone-500">
{@render sourceBadge(row.enrichment.sources?.lat)}
<span>Lat/Lng</span>
</dt>
<dd class="font-mono text-stone-700 dark:text-stone-200">
{row.enrichment.lat.toFixed(4)}, {row.enrichment.lng.toFixed(4)}
</dd>
{/if}
{#if row.enrichment.category}
<dt class="flex items-center gap-1.5 text-stone-500">
{@render sourceBadge(row.enrichment.sources?.category)}
<span>Category</span>
</dt>
<dd class="text-stone-700 dark:text-stone-200">{row.enrichment.category}</dd>
{/if}
{#if row.enrichment.opening_hours}
<dt class="flex items-center gap-1.5 text-stone-500">
{@render sourceBadge(row.enrichment.sources?.opening_hours)}
<span>Öffnungszeiten</span>
</dt>
<dd class="text-stone-700 dark:text-stone-200">{row.enrichment.opening_hours}</dd>
{/if}
{#if row.enrichment.description}
<dt class="flex items-center gap-1.5 self-start text-stone-500">
{@render sourceBadge(row.enrichment.sources?.description)}
<span>Beschreibung</span>
</dt>
<dd class="text-stone-600 dark:text-stone-300">{row.enrichment.description}</dd>
{/if}
</dl>
{:else}
<p class="text-xs text-stone-400 dark:text-stone-500">
Noch keine Enrichment-Daten. <span class="text-stone-500 dark:text-stone-400"
>Crawl-Enrich läuft im Hintergrund, oder „AI enrich" oben triggern.</span
>
</p>
{/if}
</section>
<!-- Quellen URLs -->
<section>
<section
class="rounded-lg border border-stone-200 bg-white p-5 dark:border-stone-700 dark:bg-stone-900"
>
<h3
class="text-xs font-medium tracking-wider text-stone-500 uppercase dark:text-stone-400"
class="mb-3 text-xs font-semibold tracking-wider text-stone-500 uppercase dark:text-stone-400"
>
Quellen
</h3>
{#if row.quellen && row.quellen.length > 0}
<ul class="mt-2 space-y-1 text-xs">
<ul class="space-y-1.5 text-xs">
{#each row.quellen as url, i (i)}
<li class="break-all">
<li class="flex items-baseline gap-2">
<span
class="inline-block w-20 shrink-0 truncate font-mono text-[10px] text-stone-400 dark:text-stone-500"
title={hostnameOf(url)}
>
{hostnameOf(url)}
</span>
<a
href={url}
target="_blank"
rel="noreferrer noopener"
class="text-blue-600 underline hover:text-blue-500 dark:text-blue-400"
class="truncate text-blue-600 hover:underline dark:text-blue-400"
title={url}
>
{url}
</a>
@@ -474,15 +536,17 @@
{/each}
</ul>
{:else}
<p class="mt-2 text-xs text-stone-400"></p>
<p class="text-xs text-stone-400"></p>
{/if}
</section>
<!-- Per-source contributions -->
{#if row.source_contributions && row.source_contributions.length > 1}
<section>
<section
class="rounded-lg border border-stone-200 bg-white p-5 dark:border-stone-700 dark:bg-stone-900"
>
<h3
class="text-xs font-medium tracking-wider text-stone-500 uppercase dark:text-stone-400"
class="mb-3 text-xs font-semibold tracking-wider text-stone-500 uppercase dark:text-stone-400"
>
Quellen-Vergleich ({row.sources.length})
</h3>
@@ -491,20 +555,24 @@
{/if}
<!-- Similar candidates -->
<section>
<section
class="rounded-lg border border-stone-200 bg-white p-5 dark:border-stone-700 dark:bg-stone-900"
>
<h3
class="text-xs font-medium tracking-wider text-stone-500 uppercase dark:text-stone-400"
class="mb-3 text-xs font-semibold tracking-wider text-stone-500 uppercase dark:text-stone-400"
>
Ähnliche Einträge
</h3>
{#if similarLoading}
<p class="mt-2 text-xs text-stone-400">Laden…</p>
<p class="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>
<p class="text-xs text-stone-400">Keine ähnlichen Einträge gefunden.</p>
{:else}
<table class="mt-2 w-full text-left text-xs">
<table class="w-full text-left text-xs">
<thead>
<tr class="border-b border-stone-200 text-stone-500 dark:border-stone-700">
<tr
class="border-b border-stone-200 text-stone-500 dark:border-stone-700 dark:text-stone-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>
@@ -515,23 +583,21 @@
</thead>
<tbody>
{#each similarEntries as m}
<tr class="border-b border-stone-100 dark:border-stone-800">
<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 pr-4">
<tr class="border-b border-stone-100 last:border-b-0 dark:border-stone-800">
<td class="py-1.5 pr-4 text-stone-500 tabular-nums">
{(m.score * 100).toFixed(0)}%
</td>
<td class="py-1.5 pr-4 font-medium">{m.entry.markt_name}</td>
<td class="py-1.5 pr-4">{m.entry.stadt}</td>
<td class="py-1.5 pr-4 whitespace-nowrap">{fmtDate(m.entry.start_datum)}</td>
<td class="py-1.5 pr-4">
<span
class="inline-block rounded px-1.5 py-0.5 {konfidenzClass(
m.entry.konfidenz
)}"
class="inline-block rounded px-1.5 py-0.5 {konfidenzClass(m.entry.konfidenz)}"
>
{m.entry.konfidenz || '—'}
</span>
</td>
<td class="py-1">
<td class="py-1.5">
{#if verdicts[m.entry.id]}
{@const v = verdicts[m.entry.id]}
<span
@@ -540,7 +606,8 @@
: 'bg-stone-200 text-stone-700 dark:bg-stone-700 dark:text-stone-200'}"
title={v.reason}
>
{v.same ? '✓ same' : '✗ diff'} {(v.confidence * 100).toFixed(0)}%
{v.same ? '✓ same' : '✗ diff'}
{(v.confidence * 100).toFixed(0)}%
</span>
{:else}
<button
@@ -561,15 +628,19 @@
</section>
<!-- Audit metadata -->
<section>
<section
class="rounded-lg border border-stone-200 bg-white p-5 dark:border-stone-700 dark:bg-stone-900"
>
<h3
class="text-xs font-medium tracking-wider text-stone-500 uppercase dark:text-stone-400"
class="mb-3 text-xs font-semibold tracking-wider text-stone-500 uppercase dark:text-stone-400"
>
Audit
</h3>
<dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-xs">
<dt class="text-stone-500">Discovered</dt>
<dd class="font-mono">{row.discovered_at.slice(0, 16).replace('T', ' ')}</dd>
<dd class="font-mono text-stone-700 dark:text-stone-300">
{fmtLocalStamp(row.discovered_at)}
</dd>
{#if row.agent_status && row.agent_status !== 'bestaetigt'}
<dt class="text-stone-500">Agent-Status</dt>
<dd>{row.agent_status}</dd>