mirror of
https://github.com/tiennm99/thptqg2017.git
synced 2026-09-04 00:17:48 +00:00
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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user