{% extends "layout.html.tera" %}
{% block title %}Deduplicate — {{ entity_plural | capitalize }} — {{ app_display }}{% endblock title %}
{% block content %}
<nav class="breadcrumb-nav" aria-label="Breadcrumb">
<ol class="breadcrumb-list" aria-label="Breadcrumb trail">
<li class="breadcrumb-list-item"><a href="/">Home</a></li>
<li class="breadcrumb-list-item"><a href="/{{ entity_plural }}">{{ entity_plural | capitalize }}</a></li>
<li class="breadcrumb-list-item" aria-current="page">Deduplicate</li>
</ol>
</nav>
<section aria-label="Batch deduplication scan"
x-data='{
threshold: 0.70,
autoMergeThreshold: 0.95,
maxCandidates: 50,
dryRun: true,
pin: ["", "", "", "", "", ""],
step: "config",
progressPercent: 0,
result: null,
pinJoined() { return this.pin.join(""); },
pinValid() { return this.pinJoined() === "1357"; },
pinInput(idx) {
var box = document.querySelector(".pin-input-div");
return box ? box.querySelectorAll("input")[idx] : null;
},
onPinKey(idx, evt) {
if (evt.key === "Backspace" && !this.pin[idx] && idx > 0) {
var prev = this.pinInput(idx - 1);
if (prev) prev.focus();
} else if (/^[0-9]$/.test(evt.key) && idx < 5) {
var self = this;
setTimeout(function () {
var next = self.pinInput(idx + 1);
if (next) next.focus();
}, 0);
}
},
goToVerify() {
this.step = "verify";
this.pin = ["", "", "", "", "", ""];
var self = this;
setTimeout(function () {
var first = self.pinInput(0);
if (first) first.focus();
}, 100);
},
verify() {
if (!this.pinValid()) {
if (window.lily && window.lily.toast) {
window.lily.toast("Invalid PIN — try 1357", "error");
}
return;
}
this.step = "running";
this.progressPercent = 0;
var self = this;
var tick = setInterval(function () {
self.progressPercent += 8;
if (self.progressPercent >= 100) {
clearInterval(tick);
self.progressPercent = 100;
self.result = {
scanned: 12482,
duplicates: 47,
auto_merged: self.dryRun ? 0 : 12,
queued: 35,
ms: 28471
};
self.step = "done";
if (window.lily && window.lily.toast) {
window.lily.toast("Scan complete: 47 duplicates found", "success");
}
}
}, 250);
},
reset() {
this.step = "config";
this.progressPercent = 0;
this.result = null;
this.pin = ["", "", "", "", "", ""];
}
}'>
<h2>Batch deduplication scan</h2>
<div class="information-callout" aria-label="Deduplicate explainer">
<p>
Scans every active {{ entity_singular }} and compares pairs above the
threshold. Pairs at or above the <strong>auto-merge threshold</strong>
merge automatically (when not in dry-run); the rest queue into the
<a href="/{{ entity_plural }}/review-queue">review queue</a>. This is a
heavy operation; production gates it behind a 6-digit verification PIN.
Scaffold PIN for the demo: <code>1357</code>.
</p>
</div>
<!-- Step 1: configure -->
<section x-show="step === 'config'" aria-label="Configuration step">
<h3>1. Configure</h3>
<form class="form" aria-label="Scan configuration" @submit.prevent="goToVerify()">
<div class="field" aria-label="Match threshold field">
<label class="label" for="threshold">
Match threshold: <strong x-text="threshold.toFixed(2)"></strong>
</label>
<span class="hint" id="threshold-hint">
Minimum score (0.00–1.00) for a pair to be considered a candidate.
</span>
<input class="range-input"
type="range"
id="threshold"
min="0.50" max="0.99" step="0.01"
aria-label="Match threshold"
aria-describedby="threshold-hint"
x-model.number="threshold">
</div>
<div class="field" aria-label="Auto-merge threshold field">
<label class="label" for="auto-merge">
Auto-merge threshold: <strong x-text="autoMergeThreshold.toFixed(2)"></strong>
</label>
<span class="hint" id="auto-merge-hint">
Pairs at or above this score auto-merge without review.
</span>
<input class="range-input"
type="range"
id="auto-merge"
min="0.85" max="1.00" step="0.01"
aria-label="Auto-merge threshold"
aria-describedby="auto-merge-hint"
x-model.number="autoMergeThreshold">
</div>
<div class="field" aria-label="Max candidates field">
<label class="label" for="max-cands">Max candidates per record</label>
<span class="hint" id="max-cands-hint">Cap on how many pairs we keep per record before sorting.</span>
<input class="text-input"
type="number"
id="max-cands"
min="1" max="500"
aria-label="Max candidates per record"
aria-describedby="max-cands-hint"
x-model.number="maxCandidates">
</div>
<div class="field" aria-label="Dry-run field">
<label class="label" for="dry-run">
<input id="dry-run" type="checkbox" aria-label="Dry-run mode" x-model="dryRun">
Dry-run (count only, no auto-merge)
</label>
<span class="hint">Recommended for the first run on production data.</span>
</div>
<div class="action-bar" role="toolbar" aria-label="Configuration actions">
<button class="button" type="submit" aria-label="Continue to PIN verification">Continue</button>
<a class="button" href="/{{ entity_plural }}" aria-label="Cancel and return to index">Cancel</a>
</div>
</form>
</section>
<!-- Step 2: PIN verification -->
<section x-show="step === 'verify'" aria-label="PIN verification step">
<h3>2. Verify with PIN</h3>
<p>
<span class="hint" aria-label="PIN hint">
Enter the 6-digit PIN to authorise this scan. Production posts the PIN
to <code>POST /api/auth/verify-pin</code>; this scaffold accepts
<code>1357</code>.
</span>
</p>
<form class="form" aria-label="PIN form" @submit.prevent="verify()">
<div class="pin-input-div" aria-label="6-digit PIN entry">
<template x-for="i in [0, 1, 2, 3, 4, 5]" :key="i">
<input class="text-input"
type="text"
inputmode="numeric"
maxlength="1"
pattern="[0-9]"
x-bind:aria-label="'PIN digit ' + (i + 1) + ' of 6'"
style="width: 3rem; text-align: center;"
x-model="pin[i]"
@keydown="onPinKey(i, $event)">
</template>
</div>
<div class="action-bar" role="toolbar" aria-label="Verify actions">
<button class="button"
type="submit"
aria-label="Verify PIN and start scan"
x-bind:disabled="pinJoined().length !== 6">Verify and start scan</button>
<button class="button"
type="button"
aria-label="Go back to configuration"
@click="step = 'config'">Back</button>
</div>
</form>
</section>
<!-- Step 3: running -->
<section x-show="step === 'running'" aria-label="Scan in progress step">
<h3>3. Scanning…</h3>
<p>
<progress class="progress"
x-bind:aria-label="'Scan progress ' + progressPercent + ' percent'"
max="100"
x-bind:value="progressPercent"></progress>
<span x-text="progressPercent + '%'"></span>
</p>
<div class="progress-spinner"
role="progressbar"
aria-label="Scan in progress"
aria-busy="true"
x-show="progressPercent < 100"></div>
<p>
<span class="hint" aria-label="Status">
Pairwise compare with threshold <strong x-text="threshold.toFixed(2)"></strong>,
auto-merge at <strong x-text="autoMergeThreshold.toFixed(2)"></strong>
<span x-show="dryRun">· <strong>dry-run</strong></span>.
</span>
</p>
</section>
<!-- Step 4: done -->
<section x-show="step === 'done'" aria-label="Scan results step">
<h3>4. Done</h3>
<div class="alert" role="alert" data-type="success" aria-label="Scan succeeded">
Scan complete. Duplicates queued for review.
</div>
<ol class="summary-list" aria-label="Scan results" x-show="result">
<li class="summary-list-item">
<dl>
<dt>{{ entity_plural | capitalize }} scanned</dt>
<dd>
<span class="badge" data-type="info" aria-label="Scanned count">
<span x-text="result && result.scanned.toLocaleString()"></span>
</span>
</dd>
</dl>
</li>
<li class="summary-list-item">
<dl>
<dt>Duplicates found</dt>
<dd>
<span class="badge" data-type="warning" aria-label="Duplicates count">
<span x-text="result && result.duplicates"></span>
</span>
</dd>
</dl>
</li>
<li class="summary-list-item">
<dl>
<dt>Auto-merged</dt>
<dd>
<span class="badge"
x-bind:data-type="result && result.auto_merged > 0 ? 'success' : 'info'"
aria-label="Auto-merged count">
<span x-text="result && result.auto_merged"></span>
</span>
</dd>
</dl>
</li>
<li class="summary-list-item">
<dl>
<dt>Queued for review</dt>
<dd>
<span class="badge" data-type="warning" aria-label="Queued count">
<span x-text="result && result.queued"></span>
</span>
</dd>
</dl>
</li>
<li class="summary-list-item">
<dl>
<dt>Duration</dt>
<dd><span x-text="result && (result.ms + ' ms')"></span></dd>
</dl>
</li>
</ol>
<div class="action-bar" role="toolbar" aria-label="Done actions">
<a class="button"
href="/{{ entity_plural }}/review-queue"
aria-label="Open the review queue">Open review queue</a>
<button class="button"
type="button"
aria-label="Run another scan"
@click="reset()">Run another</button>
</div>
</section>
</section>
{% endblock content %}