feat(web): search on submit, and show what a search cost

Typing no longer searches. Every keystroke used to fire a debounced
query, and a half-typed word is the expensive case: a name search seeks
on the rarest word in the query, and "bu" covers every Bùi, Bửu and Bưu
in the dataset. The query now runs when the user presses Tra cứu or
Enter, and the clear button is what empties the results.

While it runs there is a spinner and "Đang tra cứu…" with the elapsed
time. Elapsed time is the only live signal available: the worker reads
pages with synchronous XHR, so it cannot answer a getStats call until
the query it is running has finished.

Afterwards a line under the results reports what the search cost —
requests, bytes and seconds, plus the session totals — the same figures
the [httpvfs] console lines carry. A search is counted whole rather
than per query, since a name search runs two.
This commit is contained in:
2026-08-14 16:52:44 +07:00
parent c407130162
commit 175439c195
2 changed files with 66 additions and 24 deletions
+5 -22
View File
@@ -1,8 +1,6 @@
<script>
import { detectMode } from "$lib/query-mode";
const DEBOUNCE_MS = 300;
let { value = "", onSearch, onClear, disabled = false, examples = [] } = $props();
// A writable derived: it follows the owner's value — which is bound to the
@@ -10,39 +8,24 @@
// until the next external change.
let query = $derived(value);
let input = $state(null);
let timer;
const detected = $derived(detectMode(query));
const canSearch = $derived(detected.mode === "sbd" || detected.mode === "name");
// Debounced live search.
$effect(() => {
const q = query;
const mode = detected.mode;
clearTimeout(timer);
if (mode !== "sbd" && mode !== "name") {
if (mode === "empty") onClear?.();
return;
}
// Already showing results for this query — happens when the value flows in
// from URL hydration.
if (q.trim() === value.trim() && q.trim() !== "") return;
timer = setTimeout(() => onSearch(q.trim()), DEBOUNCE_MS);
return () => clearTimeout(timer);
});
// Searching only on submit, never while typing. A name search seeks on the
// rarest word the query contains, and a half-typed word is a wide prefix:
// "bu" covers every Bùi, Bửu and Bưu in the dataset, and each partial
// keystroke used to pay for that in full over the network.
function submit(event) {
event.preventDefault();
if (!canSearch) return;
clearTimeout(timer);
onSearch(query.trim());
}
function clear() {
query = "";
input?.focus();
onClear?.();
}
</script>
+61 -2
View File
@@ -9,7 +9,12 @@
import { dbSourceOf } from "$lib/datasets";
import { isExamId } from "$lib/query-mode";
import { MAX_RESULTS, lookupExamId, searchByName } from "$lib/search";
import { PLAYGROUND_BUDGET_BYTES, RemoteDatabase, SEARCH_BUDGET_BYTES } from "$lib/sqlite.svelte";
import {
PLAYGROUND_BUDGET_BYTES,
RemoteDatabase,
SEARCH_BUDGET_BYTES,
formatBytes,
} from "$lib/sqlite.svelte";
let { data } = $props();
const dataset = $derived(data.dataset);
@@ -17,6 +22,10 @@
let db = $state(null);
let results = $state(null);
let searchError = $state(null);
let searching = $state(false);
let elapsedMs = $state(0);
/** What the last search cost over the network, for the line under the results. */
let cost = $state(null);
let activeTab = $state("search");
let sqlWarningOpen = $state(false);
// Raised once the user has accepted that a hand-written query may fetch a lot.
@@ -69,19 +78,48 @@
query = q;
writeUrlQuery(q);
// Counted across the whole search rather than per query: a name search runs
// two, one for the word frequencies and one for the rows.
const before = { requests: source.requests, bytes: source.bytesRead };
const startedAt = performance.now();
searching = true;
cost = null;
// The worker reads pages with synchronous XHR, so it cannot answer while a
// query runs and there is no request count to show until it finishes.
// Elapsed time is the one honest live signal.
elapsedMs = 0;
const ticking = setInterval(() => (elapsedMs = performance.now() - startedAt), 100);
try {
results = isExamId(q) ? await lookupExamId(source, q) : await searchByName(source, q);
} catch (err) {
searchError = err instanceof Error ? err.message : String(err);
} finally {
clearInterval(ticking);
searching = false;
cost = {
requests: source.requests - before.requests,
bytes: source.bytesRead - before.bytes,
ms: performance.now() - startedAt,
sessionRequests: source.requests,
sessionBytes: source.bytesRead,
};
}
}
function clearSearch() {
results = null;
query = "";
cost = null;
searchError = null;
writeUrlQuery("");
}
/** "16,9 giây", or "0,4 giây" — one decimal is enough to compare searches. */
function seconds(ms) {
return `${(ms / 1000).toLocaleString("vi-VN", { minimumFractionDigits: 1, maximumFractionDigits: 1 })} giây`;
}
function openSqlTab() {
if (budget >= PLAYGROUND_BUDGET_BYTES) {
activeTab = "sql";
@@ -185,10 +223,21 @@
value={query}
onSearch={search}
onClear={clearSearch}
disabled={busy}
disabled={busy || searching}
examples={dataset.examples}
/>
{#if searching}
<p class="notice flex items-center justify-center gap-2 bg-surface-alt" aria-live="polite">
<span
class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-line
border-t-primary"
aria-hidden="true"
></span>
Đang tra cứu… {seconds(elapsedMs)}
</p>
{/if}
{#if searchError}
<p class="notice bg-error-bg text-error-ink">Lỗi truy vấn: {searchError}</p>
{/if}
@@ -204,6 +253,16 @@
Hiển thị tối đa {MAX_RESULTS} kết quả. Vui lòng tìm kiếm cụ thể hơn.
</p>
{/if}
{#if cost && !searching}
<p class="mt-2 text-center text-xs text-ink-subtle">
Mạng: {cost.requests.toLocaleString("vi-VN")} yêu cầu · {formatBytes(cost.bytes)} · {seconds(
cost.ms,
)} · Cả phiên: {cost.sessionRequests.toLocaleString("vi-VN")} yêu cầu · {formatBytes(
cost.sessionBytes,
)}
</p>
{/if}
{:else}
<CustomQuery {db} disabled={busy} presets={dataset.presets} />
{/if}