refactor(web): drop TypeScript for plain JavaScript

Every .ts becomes .js, every lang="ts" becomes lang-less, and the type
declarations go with them: types.ts held nothing but types, so it is
deleted outright.

Tooling follows. typescript, svelte-check, typescript-eslint and
@types/sql.js are uninstalled; tsconfig.json becomes jsconfig.json, which
still extends the generated SvelteKit config so $lib and $app resolve in
an editor; `npm run lint` is now ESLint alone, and CI's comment about it
covering the type check goes too.

What this gives up, stated plainly: a mistyped column name like
row.nguvan used to fail the build and now renders blank, and the
datasets.json-to-CONTENT cross-check is back to throwing at module load
rather than at compile time. The runtime guard for the latter is still
there and still throws loudly.

Two mechanical notes. The svelte/no-navigation-without-resolve rule
started flagging the footer's source link, which points at an off-site
article — without type information the rule can no longer tell an
external URL from a route, so that one line carries a disable comment.
And Vitest's include pattern had to follow the tests to .js.

Lint, 25 tests and the build all pass.
This commit is contained in:
tiennm99 committed 2026-08-14 13:49:52 +07:00
1 parent d7e2fa2d12
commit b38965dc4b
32 files changed
+121 -712

No files matched your search

+2 -2
View File
@@ -66,8 +66,8 @@ jobs:
- name: Test assembler
run: go -C assembler test ./...
# Lint covers ESLint and svelte-check; the tests cover the framework-free
# modules, including the ASCII fold that has to match the Go parser.
# The tests cover the framework-free modules, including the ASCII fold
# that has to match the Go parser.
- name: Test web
working-directory: web
run: npm test
+4 -3
View File
@@ -24,7 +24,8 @@ Add the web checks when frontend files changed:
(cd web && npm test && npm run lint && npm run build)
```
`npm run lint` is ESLint plus `svelte-check`, so it is the type check too.
`npm run lint` is ESLint. The web app is plain JavaScript — no TypeScript, no
type-check step.
**Do not run the crawler suite as part of routine verification.** The crawler is
not part of the build — `data/<id>/` is committed and a crawl only refreshes it
@@ -44,8 +45,8 @@ hashes every real input file. That is the point of it; do not skip it.
regenerated.** A mismatch is a reader bug until proven otherwise, never a cue
to refresh the file. `parser/cmd/dumpcells` narrows a failure to the cell.
- **`ToAscii` filters the literal range U+0300..U+036F, not `unicode.Mn`.** It
must match `toAscii` in `web/src/lib/to-ascii.ts`, or accent-insensitive search
silently misses rows. `to-ascii.test.ts` pins the pairs both sides must agree
must match `toAscii` in `web/src/lib/to-ascii.js`, or accent-insensitive search
silently misses rows. `to-ascii.test.js` pins the pairs both sides must agree
on.
- **The 2016 files use four different layouts, and detection is per sheet.**
Two of them publish scores in one column per subject instead of a `DIEM_THI`
+2 -2
View File
@@ -37,7 +37,7 @@ Each stage runs on its own and hands its output to the next through the stores.
`datasets.json` is the contract between them. It is JSON because Go and the web
app both read it and neither needs a dependency to do so; presentation stays in
`web/src/lib/datasets.ts`, keyed by id, which fails loudly if the two disagree.
`web/src/lib/datasets.js`, keyed by id, which fails loudly if the two disagree.
The dataset id is one identifier end to end:
@@ -86,7 +86,7 @@ Pushing to `main` runs the same steps in
2. Add `parser/configs/<id>.yml` — sheet mode, column indices, validation
guards. No SQL; the schema is canonical.
3. Add an entry to `datasets.json` with its expected row count and size
4. Add the matching presentation to `CONTENT` in `web/src/lib/datasets.ts`
4. Add the matching presentation to `CONTENT` in `web/src/lib/datasets.js`
Everything else follows: the assembler, the router and the hub all read the
registry, and the UI adapts to whichever columns the dataset fills. Steps 3 and 4
+1 -1
View File
@@ -183,7 +183,7 @@ silently drops 13,720 students** (Hanoi +7,275, HCM +6,445). That is what
The databases are written with 1 KiB pages (`PRAGMA page_size` in
`parser/internal/writer/writer.go`) because the browser fetches them a page per
HTTP request. `CHUNK_BYTES` in `web/src/lib/sqlite.svelte.ts` must match.
HTTP request. `CHUNK_BYTES` in `web/src/lib/sqlite.svelte.js` must match.
| id | Source rows | Skipped | DB rows |
| --- | --- | --- | --- |
+4 -4
View File
@@ -46,7 +46,7 @@ because the assembler is a Go program and the web app is not, and JSON is the
only format both parse without a dependency.
Presentation — titles, labels, search examples, SQL presets — stays in
`web/src/lib/datasets.ts`, keyed by id. That file cross-checks the two: a registry
`web/src/lib/datasets.js`, keyed by id. That file cross-checks the two: a registry
entry with no content, or content for a dataset that was never built, throws at
module load rather than rendering a page with no title or a link to a database
that does not exist.
@@ -125,13 +125,13 @@ No component contains a per-dataset conditional. Two mechanisms do the work:
self-exclude wherever those languages were not sat.
Anything genuinely per-dataset — title, source, database size, search examples,
SQL presets — lives in `web/src/lib/datasets.ts`.
SQL presets — lives in `web/src/lib/datasets.js`.
## Exam ID formats
`web/src/lib/query-mode.ts` decides whether a query is an exam ID or a name, and
`web/src/lib/query-mode.js` decides whether a query is an exam ID or a name, and
is shared by the dataset page and `search-form.svelte` (they previously held
separate copies and had drifted apart on exactly this rule). `query-mode.test.ts`
separate copies and had drifted apart on exactly this rule). `query-mode.test.js`
covers every form in the table below.
| Form | Example | Where |
+1 -1
View File
@@ -41,7 +41,7 @@ reader independently verifiable against a hash oracle.
- `ToAscii` strips combining marks in the literal range U+0300–U+036F rather
than by Unicode category. That covers every Vietnamese diacritic and must
stay identical to `toAscii` in `web/src/lib/to-ascii.ts`, or accent-insensitive
stay identical to `toAscii` in `web/src/lib/to-ascii.js`, or accent-insensitive
search misses rows.
- Gender is normalised to `Nam`/`Nữ`; the Cần Thơ files write `0`/`1` instead
and are translated. Anything else becomes NULL.
+5 -12
View File
@@ -1,12 +1,10 @@
import js from "@eslint/js";
import svelte from "eslint-plugin-svelte";
import globals from "globals";
import ts from "typescript-eslint";
import svelteConfig from "./svelte.config.js";
export default ts.config(
export default [
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
{
languageOptions: {
@@ -15,16 +13,11 @@ export default ts.config(
},
{
// Svelte files and rune modules are parsed by svelte-eslint-parser, which
// needs the TypeScript parser handed to it for `lang="ts"` blocks.
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
// needs the project's svelte.config.js to resolve aliases and runes.
files: ["**/*.svelte", "**/*.svelte.js"],
languageOptions: {
parserOptions: {
parser: ts.parser,
projectService: true,
extraFileExtensions: [".svelte"],
svelteConfig,
},
parserOptions: { svelteConfig },
},
},
{ ignores: ["dist/", ".svelte-kit/", "node_modules/"] },
);
];
+3 -6
View File
@@ -1,14 +1,11 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
"sourceMap": true
}
}
-473
View File
@@ -16,15 +16,11 @@
"@sveltejs/kit": "^2.70.2",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.3.3",
"@types/sql.js": "^1.4.11",
"eslint": "^9.39.4",
"eslint-plugin-svelte": "^3.14.0",
"globals": "^17.4.0",
"svelte": "^5.56.9",
"svelte-check": "^4.4.3",
"tailwindcss": "^4.3.3",
"typescript": "^5.9.3",
"typescript-eslint": "^8.48.2",
"vite": "^7.1.14",
"vitest": "^4.1.10"
}
@@ -1187,16 +1183,6 @@
}
}
},
"node_modules/@sveltejs/load-config": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz",
"integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 18.0.0"
}
},
"node_modules/@sveltejs/vite-plugin-svelte": {
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz",
@@ -1818,13 +1804,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/emscripten": {
"version": "1.41.5",
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
"integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"dev": true,
@@ -1835,27 +1814,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/sql.js": {
"version": "1.4.11",
"resolved": "https://registry.npmjs.org/@types/sql.js/-/sql.js-1.4.11.tgz",
"integrity": "sha512-QXIx38p2ZThJaK9vP5ZdqdlRe1FG9I8SmCZOS7FHfB/2qPAjZwkL7/vlfPg6N/oWHuuOaGg/P/IRwfP2W0kWVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/emscripten": "*",
"@types/node": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -1863,301 +1821,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz",
"integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.67.0",
"@typescript-eslint/type-utils": "8.67.0",
"@typescript-eslint/utils": "8.67.0",
"@typescript-eslint/visitor-keys": "8.67.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.67.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz",
"integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz",
"integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.67.0",
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/typescript-estree": "8.67.0",
"@typescript-eslint/visitor-keys": "8.67.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz",
"integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.67.0",
"@typescript-eslint/types": "^8.67.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz",
"integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/visitor-keys": "8.67.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz",
"integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz",
"integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/typescript-estree": "8.67.0",
"@typescript-eslint/utils": "8.67.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz",
"integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz",
"integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.67.0",
"@typescript-eslint/tsconfig-utils": "8.67.0",
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/visitor-keys": "8.67.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.8"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz",
"integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.67.0",
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/typescript-estree": "8.67.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz",
"integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.67.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@vitest/expect": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
@@ -2401,22 +2064,6 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -3189,16 +2836,6 @@
"node": "*"
}
},
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
@@ -3495,20 +3132,6 @@
"node": ">=6"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/resolve-from": {
"version": "4.0.0",
"dev": true,
@@ -3563,19 +3186,6 @@
"fsevents": "~2.3.2"
}
},
"node_modules/sade": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
"integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
"dev": true,
"license": "MIT",
"dependencies": {
"mri": "^1.1.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/set-cookie-parser": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz",
@@ -3705,31 +3315,6 @@
"node": ">=18"
}
},
"node_modules/svelte-check": {
"version": "4.7.6",
"resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.6.tgz",
"integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.25",
"@sveltejs/load-config": "^0.2.3",
"chokidar": "^4.0.1",
"fdir": "^6.2.0",
"picocolors": "^1.0.0",
"sade": "^1.7.4"
},
"bin": {
"svelte-check": "bin/svelte-check"
},
"engines": {
"node": ">= 18.0.0"
},
"peerDependencies": {
"svelte": "^4.0.0 || ^5.0.0-next.0",
"typescript": "^5.0.0 || ^6.0.0"
}
},
"node_modules/svelte-eslint-parser": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.8.0.tgz",
@@ -3847,19 +3432,6 @@
"node": ">=6"
}
},
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.12"
},
"peerDependencies": {
"typescript": ">=4.8.4"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"dev": true,
@@ -3871,51 +3443,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/typescript-eslint": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz",
"integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.67.0",
"@typescript-eslint/parser": "8.67.0",
"@typescript-eslint/typescript-estree": "8.67.0",
"@typescript-eslint/utils": "8.67.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/uri-js": {
"version": "4.4.1",
"dev": true,
+1 -5
View File
@@ -9,7 +9,7 @@
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"lint": "eslint . && svelte-check --tsconfig ./tsconfig.json"
"lint": "eslint ."
},
"dependencies": {
"sql.js-httpvfs": "^0.8.12"
@@ -20,15 +20,11 @@
"@sveltejs/kit": "^2.70.2",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.3.3",
"@types/sql.js": "^1.4.11",
"eslint": "^9.39.4",
"eslint-plugin-svelte": "^3.14.0",
"globals": "^17.4.0",
"svelte": "^5.56.9",
"svelte-check": "^4.4.3",
"tailwindcss": "^4.3.3",
"typescript": "^5.9.3",
"typescript-eslint": "^8.48.2",
"vite": "^7.1.14",
"vitest": "^4.1.10"
}
@@ -1,15 +1,3 @@
import type { Student, SubjectKey } from "./types";
export type AdmissionBlock = { code: string; subjects: SubjectKey[]; label: string };
export type ComputedBlock = {
code: string;
label: string;
total: number;
parts: { key: SubjectKey; score: number }[];
};
export type TierKey = "common" | "uncommon" | "rare" | "epic" | "legendary" | "prismatic";
export type Tier = { key: TierKey; symbol: string; label: string };
// Vietnamese university admission-block ("khối thi") definitions, per Circular
// 03/2017/TT-BGDĐT. Each block is the sum of exactly 3 subject scores.
//
@@ -17,7 +5,7 @@ export type Tier = { key: TierKey; symbol: string; label: string };
// skips any block with a missing subject score, so blocks needing GDCD
// self-exclude on 2016 rows, and blocks needing a language the candidate did
// not sit self-exclude row by row.
export const ADMISSION_BLOCKS: AdmissionBlock[] = [
export const ADMISSION_BLOCKS = [
{ code: "A00", subjects: ["toan", "vat_ly", "hoa_hoc"], label: "Toán + Lý + Hóa" },
{ code: "A01", subjects: ["toan", "vat_ly", "tieng_anh"], label: "Toán + Lý + Anh" },
{ code: "A02", subjects: ["toan", "vat_ly", "sinh_hoc"], label: "Toán + Lý + Sinh" },
@@ -76,14 +64,13 @@ export const ADMISSION_BLOCKS: AdmissionBlock[] = [
// Returns { code, label, total, parts:[{key,score}] } for every block where
// the student has scores for all three subjects, sorted by total desc.
export function computeBlocks(student: Student): ComputedBlock[] {
const out: ComputedBlock[] = [];
export function computeBlocks(student) {
const out = [];
for (const b of ADMISSION_BLOCKS) {
const parts = b.subjects.map((k) => ({ key: k, score: student[k] }));
if (parts.some((p) => p.score === null || p.score === undefined)) continue;
const whole = parts as { key: SubjectKey; score: number }[];
const total = whole.reduce((s, p) => s + p.score, 0);
out.push({ code: b.code, label: b.label, total, parts: whole });
const total = parts.reduce((s, p) => s + p.score, 0);
out.push({ code: b.code, label: b.label, total, parts });
}
return out.sort((a, b) => b.total - a.total);
}
@@ -93,7 +80,7 @@ export function computeBlocks(student: Student): ComputedBlock[] {
// whatever the other scores are, so that boundary is inclusive while the rest
// are exclusive upper bounds. Every tier carries a unicode symbol as well as a
// color, so the meaning is never conveyed by color alone.
export function scoreTier(score: number | null | undefined): Tier | null {
export function scoreTier(score) {
if (score === null || score === undefined) return null;
if (score <= 1) return { key: "common", symbol: "·", label: "Điểm liệt" };
if (score < 5) return { key: "uncommon", symbol: "○", label: "Chưa đạt" };
@@ -1,9 +1,8 @@
import { describe, expect, it } from "vitest";
import { ADMISSION_BLOCKS, computeBlocks, scoreTier } from "./admission-blocks";
import type { Student } from "./types";
/** A row with every column NULL, so a test only states the scores it needs. */
function student(scores: Partial<Student>): Student {
function student(scores) {
return {
so_bao_danh: "49008235",
ho_ten: "Nguyễn Văn A",
+10 -15
View File
@@ -1,29 +1,24 @@
<script lang="ts">
import { formatBytes, isBudgetError, type RemoteDatabase } from "$lib/sqlite.svelte";
import type { PresetGroup } from "$lib/types";
<script>
import { formatBytes, isBudgetError } from "$lib/sqlite.svelte";
const MAX_ROWS = 1000;
const PLACEHOLDER = "Nhập truy vấn SQL...\nVí dụ: SELECT * FROM student WHERE toan >= 9 LIMIT 10";
let {
db,
disabled = false,
presets = [],
}: { db: RemoteDatabase | null; disabled?: boolean; presets?: PresetGroup[] } = $props();
let { db, disabled = false, presets = [] } = $props();
// By convention every preset list ends with a "Hệ thống" group whose first
// query dumps the table schema. See lib/sql-presets.ts.
const schemaPreset = $derived(presets[presets.length - 1]?.queries[0]);
let sql = $state("");
let columns = $state<string[]>([]);
let rows = $state<Record<string, unknown>[]>([]);
let queryError = $state<string | null>(null);
let execTime = $state<string | null>(null);
let columns = $state([]);
let rows = $state([]);
let queryError = $state(null);
let execTime = $state(null);
let running = $state(false);
let autoRan = false;
async function execute(queryStr: string) {
async function execute(queryStr) {
const source = db;
if (!source?.ready) return;
queryError = null;
@@ -52,7 +47,7 @@
running = true;
const start = performance.now();
try {
const result = await source.query<Record<string, unknown>>(finalSql, [], "SQL tab");
const result = await source.query(finalSql, [], "SQL tab");
execTime = (performance.now() - start).toFixed(1);
rows = result.slice(0, MAX_ROWS);
columns = rows.length > 0 ? Object.keys(rows[0]) : [];
@@ -68,7 +63,7 @@
}
}
function runPreset(presetSql: string) {
function runPreset(presetSql) {
sql = presetSql;
void execute(presetSql);
}
+3 -4
View File
@@ -1,11 +1,10 @@
<script lang="ts">
<script>
import { scoreTier } from "$lib/admission-blocks";
import { IDENTITY_COLUMNS, SUBJECTS, hasAnyValue } from "$lib/subjects";
import type { Student } from "$lib/types";
let { results }: { results: Student[] | null } = $props();
let { results } = $props();
function formatScore(val: number | null): string {
function formatScore(val) {
return val === null || val === undefined ? "—" : Number(val).toFixed(2);
}
+5 -17
View File
@@ -1,28 +1,16 @@
<script lang="ts">
<script>
import { detectMode } from "$lib/query-mode";
const DEBOUNCE_MS = 300;
let {
value = "",
onSearch,
onClear,
disabled = false,
examples = [],
}: {
value?: string;
onSearch: (q: string) => void;
onClear?: () => void;
disabled?: boolean;
examples?: string[];
} = $props();
let { value = "", onSearch, onClear, disabled = false, examples = [] } = $props();
// A writable derived: it follows the owner's value — which is bound to the
// URL, so a deep link or the clear button flows in — and typing overrides it
// until the next external change.
let query = $derived(value);
let input = $state<HTMLInputElement | null>(null);
let timer: ReturnType<typeof setTimeout> | undefined;
let input = $state(null);
let timer;
const detected = $derived(detectMode(query));
const canSearch = $derived(detected.mode === "sbd" || detected.mode === "name");
@@ -45,7 +33,7 @@
return () => clearTimeout(timer);
});
function submit(event: SubmitEvent) {
function submit(event) {
event.preventDefault();
if (!canSearch) return;
clearTimeout(timer);
+7 -8
View File
@@ -1,9 +1,8 @@
<script lang="ts">
<script>
import { computeBlocks, scoreTier } from "$lib/admission-blocks";
import { SUBJECTS } from "$lib/subjects";
import type { Student, SubjectKey } from "$lib/types";
let { student }: { student: Student } = $props();
let { student } = $props();
// Visible legend, so a user does not have to hover tiles to decode the
// colours. The ranges must stay in step with scoreTier() in
@@ -17,22 +16,22 @@
{ key: "prismatic", symbol: "❖", range: "9-10", label: "Xuất sắc" },
];
let copied = $state<null | "sbd" | "share">(null);
let copied = $state(null);
const blocks = $derived(computeBlocks(student));
const subjects = $derived(
SUBJECTS.filter((s) => student[s.key] !== null && student[s.key] !== undefined).map((s) => ({
key: s.key as SubjectKey,
key: s.key,
label: s.label,
score: student[s.key] as number,
score: student[s.key],
})),
);
function fmt(n: number | null | undefined): string {
function fmt(n) {
return n === null || n === undefined ? "—" : Number(n).toFixed(2);
}
function flash(kind: "sbd" | "share") {
function flash(kind) {
copied = kind;
setTimeout(() => (copied = null), 1500);
}
@@ -13,7 +13,6 @@
import registry from "../../../datasets.json";
import { PRESETS_2016, PRESETS_2017 } from "./sql-presets";
import type { Dataset, PresetGroup } from "./types";
const SUBTITLE = "Dữ liệu thí sinh toàn quốc · Hỗ trợ truy vấn SQL tùy chỉnh";
@@ -25,9 +24,7 @@ const SUBTITLE = "Dữ liệu thí sinh toàn quốc · Hỗ trợ truy vấn SQ
* (crawler/internal/sources/source_<id>.go); it is duplicated here because a Go
* module and a Vite app cannot share a constant. Keep the two in step.
*/
type Content = Omit<Dataset, "id" | "dbSizeMb" | "blurb" | "rows"> & { presets: PresetGroup[] };
const CONTENT: Record<string, Content> = {
const CONTENT = {
2016: {
label: "Kỳ thi 2016",
title: "Tra cứu điểm thi THPT Quốc gia 2016",
@@ -64,7 +61,7 @@ for (const id of Object.keys(CONTENT)) {
}
}
export const DATASETS: Dataset[] = registry.datasets.map((d) => ({
export const DATASETS = registry.datasets.map((d) => ({
id: d.id,
dbSizeMb: d.dbSizeMb,
rows: d.expectedRows,
@@ -75,7 +72,7 @@ export const DATASETS: Dataset[] = registry.datasets.map((d) => ({
}));
/** Look up a dataset by the route segment, or undefined for an unknown id. */
export function datasetById(id: string): Dataset | undefined {
export function datasetById(id) {
return DATASETS.find((d) => d.id === id);
}
@@ -83,7 +80,7 @@ export function datasetById(id: string): Dataset | undefined {
* Site path for a dataset. `base` is SvelteKit's, which carries no trailing
* slash: pathOf(d, "/thptqg") → "/thptqg/2017/".
*/
export function pathOf(dataset: Dataset, base: string): string {
export function pathOf(dataset, base) {
return `${base}/${dataset.id}/`;
}
@@ -93,6 +90,6 @@ export function pathOf(dataset: Dataset, base: string): string {
* Uncompressed on purpose: the browser reads byte ranges of it, and a range of
* a gzip stream is not a range of the database.
*/
export function dbOf(dataset: Dataset, base: string): string {
export function dbOf(dataset, base) {
return `${base}/db/${dataset.id}.sqlite3`;
}
@@ -18,11 +18,8 @@ const SBD_PATTERN = /^[A-Za-z]{0,4}\d+$/;
export const MIN_SBD_DIGITS = 3;
export const MIN_NAME_CHARS = 2;
/** How a query was classified, and the hint shown beneath the field. */
export type QueryMode = "empty" | "sbd" | "sbd-short" | "name" | "name-short";
/** True when the query looks like an exam ID rather than a name. */
export function isExamId(query: string): boolean {
export function isExamId(query) {
return SBD_PATTERN.test(query.trim());
}
@@ -30,19 +27,20 @@ export function isExamId(query: string): boolean {
* Normalise an exam ID for lookup. Letter prefixes are stored upper-case, so a
* user typing "bal000001" still matches. No-op for all-digit IDs.
*/
export function normaliseExamId(query: string): string {
export function normaliseExamId(query) {
return query.trim().toUpperCase();
}
function digitCount(str: string): number {
function digitCount(str) {
return (str.match(/\d/g) ?? []).length;
}
/**
* Decide what the user is searching for, and what hint to show beneath the
* field.
* field. Returns `{ mode, hint }` where mode is one of:
* empty | sbd | sbd-short | name | name-short.
*/
export function detectMode(raw: string): { mode: QueryMode; hint: string } {
export function detectMode(raw) {
const q = raw.trim();
if (!q) return { mode: "empty", hint: "Gõ SBD (số báo danh) hoặc họ tên để tìm" };
File renamed without changes.
@@ -1,7 +1,5 @@
import { normaliseExamId } from "./query-mode";
import type { RemoteDatabase } from "./sqlite.svelte";
import { toAscii } from "./to-ascii";
import type { Student } from "./types";
export const MAX_RESULTS = 100;
@@ -22,7 +20,7 @@ export const MAX_RESULTS = 100;
*
* Every step is an index seek. A search costs a few hundred KB.
*/
export async function searchByName(db: RemoteDatabase, query: string): Promise<Student[]> {
export async function searchByName(db, query) {
const words = tokenise(query);
if (words.length === 0) return [];
@@ -38,7 +36,7 @@ export async function searchByName(db: RemoteDatabase, query: string): Promise<S
WHERE w.word >= ? AND w.word < ?${filters.length ? " AND " + filters.join(" AND ") : ""}
LIMIT ${MAX_RESULTS}`;
return db.query<Student>(
return db.query(
sql,
[seek, upperBound(seek), ...others.map((w) => `% ${escapeLike(w)}%`)],
`name ${JSON.stringify(words.join(" "))} seeking ${JSON.stringify(seek)}`,
@@ -46,8 +44,8 @@ export async function searchByName(db: RemoteDatabase, query: string): Promise<S
}
/** Exact lookup by exam number: a primary-key seek, a few pages. */
export async function lookupExamId(db: RemoteDatabase, id: string): Promise<Student[]> {
return db.query<Student>(
export async function lookupExamId(db, id) {
return db.query(
"SELECT * FROM student WHERE so_bao_danh = ? LIMIT ?",
[normaliseExamId(id), MAX_RESULTS],
`exam id ${JSON.stringify(normaliseExamId(id))}`,
@@ -55,7 +53,7 @@ export async function lookupExamId(db: RemoteDatabase, id: string): Promise<Stud
}
/** Fold to ASCII and split into words, the same shape name_word was built in. */
export function tokenise(query: string): string[] {
export function tokenise(query) {
return toAscii(query).split(/\s+/).filter(Boolean);
}
@@ -63,7 +61,7 @@ export function tokenise(query: string): string[] {
* The word whose prefix covers the fewest entries, which is the one worth
* seeking on. One round trip for all of them.
*/
async function rarest(db: RemoteDatabase, words: string[]): Promise<string> {
async function rarest(db, words) {
if (words.length === 1) return words[0];
const sql = words
@@ -71,7 +69,7 @@ async function rarest(db: RemoteDatabase, words: string[]): Promise<string> {
.join(" UNION ALL ");
const params = words.flatMap((w) => [w, w, upperBound(w)]);
const counts = await db.query<{ word: string; n: number }>(sql, params, "word frequencies");
const counts = await db.query(sql, params, "word frequencies");
let best = words[0];
let bestN = Infinity;
for (const { word, n } of counts) {
@@ -88,11 +86,11 @@ async function rarest(db: RemoteDatabase, words: string[]): Promise<string> {
* that can follow the prefix, which is what turns "starts with" into a range
* the index can seek.
*/
function upperBound(prefix: string): string {
function upperBound(prefix) {
return prefix + "￿";
}
/** Escape the LIKE wildcards so a user typing % or _ searches for them. */
function escapeLike(s: string): string {
function escapeLike(s) {
return s.replace(/[\\%_]/g, (c) => `\\${c}`);
}
@@ -1,5 +1,3 @@
import type { PresetGroup } from "./types";
/**
* SQL presets shown in the "Truy vấn SQL" tab, per dataset.
*
@@ -13,7 +11,7 @@ import type { PresetGroup } from "./types";
* before the user writes anything.
*/
export const PRESETS_2017: PresetGroup[] = [
export const PRESETS_2017 = [
{
category: "Xếp hạng môn",
queries: [
@@ -181,7 +179,7 @@ FROM student`,
],
},
];
export const PRESETS_2016: PresetGroup[] = [
export const PRESETS_2016 = [
{
category: "Xếp hạng môn",
queries: [
@@ -1,4 +1,4 @@
import { createDbWorker, type SqliteStats, type WorkerHttpvfs } from "sql.js-httpvfs";
import { createDbWorker } from "sql.js-httpvfs";
import workerUrl from "sql.js-httpvfs/dist/sqlite.worker.js?url";
import wasmUrl from "sql.js-httpvfs/dist/sql-wasm.wasm?url";
@@ -25,8 +25,9 @@ export const SEARCH_BUDGET_BYTES = 25 * 1024 * 1024;
/** What the SQL tab gets once the user has accepted the cost of a scan. */
export const PLAYGROUND_BUDGET_BYTES = 250 * 1024 * 1024;
/** What one query cost over the network. */
export type QueryCost = { requests: number; bytes: number; ms: number };
/**
* What one query cost over the network: `{ requests, bytes, ms }`.
*/
/**
* One remotely-paged database, with the load state the UI needs.
@@ -37,28 +38,27 @@ export type QueryCost = { requests: number; bytes: number; ms: number };
*/
export class RemoteDatabase {
ready = $state(false);
error = $state<string | null>(null);
error = $state(null);
/** Bytes fetched by this database so far, prefetch included. */
bytesRead = $state(0);
/** HTTP range requests issued so far. */
requests = $state(0);
/** What the most recent query cost on its own. */
lastCost = $state<QueryCost | null>(null);
lastCost = $state(null);
#worker: WorkerHttpvfs | null = null;
#opening: Promise<WorkerHttpvfs>;
#worker = null;
#opening;
#closed = false;
// Previous cumulative reading, so a query's own cost is a subtraction.
#seen = { requests: 0, bytes: 0 };
constructor(
readonly url: string,
readonly budgetBytes: number = SEARCH_BUDGET_BYTES,
) {
constructor(url, budgetBytes = SEARCH_BUDGET_BYTES) {
this.url = url;
this.budgetBytes = budgetBytes;
this.#opening = this.#open();
}
async #open(): Promise<WorkerHttpvfs> {
async #open() {
const opened = performance.now();
try {
const worker = await createDbWorker(
@@ -90,14 +90,11 @@ export class RemoteDatabase {
* search actually costs, since the byte count depends on how much the read
* heads prefetched, not just on the pages the plan needed.
*/
async query<T>(sql: string, params: unknown[] = [], label?: string): Promise<T[]> {
async query(sql, params = [], label) {
const worker = this.#worker ?? (await this.#opening);
// Comlink erases the generic when it proxies the method across the worker
// boundary, so the row type is asserted here rather than inferred.
const run = worker.db.query as unknown as (sql: string, ...params: unknown[]) => Promise<T[]>;
const started = performance.now();
try {
return await run(sql, ...params);
return await worker.db.query(sql, ...params);
} finally {
await this.#account(worker, label ?? firstLine(sql), performance.now() - started);
}
@@ -110,12 +107,11 @@ export class RemoteDatabase {
* counter and resets itself to zero when a query trips the ceiling, so it
* would under-report exactly when the number matters most.
*/
async #account(worker: WorkerHttpvfs, label: string, ms: number) {
const read = worker.worker.getStats as unknown as () => Promise<SqliteStats | null>;
const stats = await read().catch(() => null);
async #account(worker, label, ms) {
const stats = await worker.worker.getStats().catch(() => null);
if (!stats) return;
const cost: QueryCost = {
const cost = {
requests: stats.totalRequests - this.#seen.requests,
bytes: stats.totalFetchedBytes - this.#seen.bytes,
ms,
@@ -146,20 +142,20 @@ export class RemoteDatabase {
}
/** True when a query failed because it would have exceeded the byte budget. */
export function isBudgetError(err: unknown): boolean {
export function isBudgetError(err) {
return /maxBytesToRead|too much data|exceeded/i.test(message(err));
}
function message(err: unknown): string {
function message(err) {
return err instanceof Error ? err.message : String(err);
}
/** Enough of a query to recognise it in the console. */
function firstLine(sql: string): string {
function firstLine(sql) {
const line = sql.trim().split("\n")[0];
return line.length > 70 ? `${line.slice(0, 70)}…` : line;
}
export function formatBytes(n: number): string {
export function formatBytes(n) {
return n < 1024 * 1024 ? `${Math.round(n / 1024)} KB` : `${(n / 1048576).toFixed(1)} MB`;
}
@@ -1,5 +1,3 @@
import type { IdentityKey, Student, SubjectKey } from "./types";
/**
* The 16 subject columns of the canonical schema, in display order. Mirrors
* `ScoreFields` in parser/internal/schema/schema.go, and is the single list both
@@ -10,7 +8,7 @@ import type { IdentityKey, Student, SubjectKey } from "./types";
* years without branching.
*/
export const SUBJECTS: { key: SubjectKey; label: string }[] = [
export const SUBJECTS = [
{ key: "toan", label: "Toán" },
{ key: "ngu_van", label: "Ngữ văn" },
{ key: "vat_ly", label: "Vật lí" },
@@ -34,12 +32,12 @@ export const SUBJECTS: { key: SubjectKey; label: string }[] = [
* populates these; the same all-NULL filter that hides unused subjects hides
* them elsewhere, so no per-dataset conditional is needed.
*/
export const IDENTITY_COLUMNS: { key: IdentityKey; label: string }[] = [
export const IDENTITY_COLUMNS = [
{ key: "ten_cum_thi", label: "Cụm thi" },
{ key: "gioi_tinh", label: "GT" },
];
/** True when at least one row carries a value for `key`. */
export function hasAnyValue(rows: Student[], key: SubjectKey | IdentityKey): boolean {
export function hasAnyValue(rows, key) {
return rows.some((row) => row[key] !== null && row[key] !== undefined);
}
@@ -12,7 +12,7 @@
*
* to-ascii.test.ts pins the pairs both sides have to agree on.
*/
export function toAscii(str: string): string {
export function toAscii(str) {
return str
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
@@ -21,7 +21,7 @@ export function toAscii(str: string): string {
}
/** True when every character is ASCII, i.e. the query carries no diacritics. */
export function isAsciiOnly(str: string): boolean {
export function isAsciiOnly(str) {
for (let i = 0; i < str.length; i++) {
if (str.charCodeAt(i) > 127) return false;
}
@@ -8,7 +8,7 @@ import { isAsciiOnly, toAscii } from "./to-ascii";
* search silently misses rows. The cases below are the ones
* parser/internal/transform/transform_test.go pins on the Go side.
*/
const GO_PARITY: [input: string, want: string][] = [
const GO_PARITY = [
["Nguyễn Bửu Lộc", "nguyen buu loc"],
["NGUYỄN THỊ HOA", "nguyen thi hoa"],
["Trần Thị Phước An", "tran thi phuoc an"],
-56
View File
@@ -1,56 +0,0 @@
/**
* The shapes the app shares. `Student` mirrors the 22-column table in
* parser/internal/schema/schema.go — every column is nullable except the three
* the schema declares NOT NULL, so a typo in a column name fails the build
* instead of rendering blank.
*/
export type SubjectKey =
| "toan"
| "ngu_van"
| "vat_ly"
| "hoa_hoc"
| "sinh_hoc"
| "khtn"
| "lich_su"
| "dia_ly"
| "gdcd"
| "khxh"
| "tieng_anh"
| "tieng_phap"
| "tieng_nga"
| "tieng_duc"
| "tieng_nhat"
| "tieng_trung";
export type IdentityKey = "ten_cum_thi" | "gioi_tinh";
export type Student = {
so_bao_danh: string;
ho_ten: string;
ho_ten_ascii: string;
ngay_sinh: string | null;
} & { [K in IdentityKey]: string | null } & { [K in SubjectKey]: number | null };
/** A dataset as the interface needs it: registry facts plus presentation. */
export type Dataset = {
id: string;
/** Size of the hosted database. Nothing downloads it whole; it is shown so a
* user knows what they are querying into. */
dbSizeMb: number;
/** Row count from the registry, so the footer never runs COUNT(*) — that
* scans an index and would cost 20 MB over range requests. */
rows: number;
blurb: string;
label: string;
title: string;
subtitle: string;
source: string;
examples: string[];
presets: PresetGroup[];
};
export type PresetGroup = {
category: string;
queries: { label: string; sql: string }[];
};
File renamed without changes.
+1 -1
View File
@@ -1,4 +1,4 @@
<script lang="ts">
<script>
import "../app.css";
let { children } = $props();
+2 -3
View File
@@ -1,12 +1,11 @@
<script lang="ts">
<script>
import { resolve } from "$app/paths";
import { DATASETS } from "$lib/datasets";
import type { Dataset } from "$lib/types";
// Trailing slash on purpose: each page is prerendered as <id>/index.html, and
// hitting it without the slash costs a GitHub Pages directory redirect.
// resolve() still supplies the base path.
function hrefOf(dataset: Dataset): string {
function hrefOf(dataset) {
return `${resolve("/[dataset]", { dataset: dataset.id })}/`;
}
</script>
@@ -1,15 +1,14 @@
import { error } from "@sveltejs/kit";
import { DATASETS, datasetById } from "$lib/datasets";
import type { EntryGenerator, PageLoad } from "./$types";
/**
* One prerendered page per registered dataset. The registry is the repository
* root's datasets.json, so a dataset the assembler builds is a page that exists,
* and one it does not is a 404 at build time rather than a blank page.
*/
export const entries: EntryGenerator = () => DATASETS.map((d) => ({ dataset: d.id }));
export const entries = () => DATASETS.map((d) => ({ dataset: d.id }));
export const load: PageLoad = ({ params }) => {
export const load = ({ params }) => {
const dataset = datasetById(params.dataset);
if (!dataset) throw error(404, `Unknown dataset: ${params.dataset}`);
return { dataset };
+11 -10
View File
@@ -1,4 +1,4 @@
<script lang="ts">
<script>
import { browser } from "$app/environment";
import { replaceState } from "$app/navigation";
import { base, resolve } from "$app/paths";
@@ -11,15 +11,14 @@
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 type { Student } from "$lib/types";
let { data } = $props();
const dataset = $derived(data.dataset);
let db = $state<RemoteDatabase | null>(null);
let results = $state<Student[] | null>(null);
let searchError = $state<string | null>(null);
let activeTab = $state<"search" | "sql">("search");
let db = $state(null);
let results = $state(null);
let searchError = $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.
let budget = $state(SEARCH_BUDGET_BYTES);
@@ -53,7 +52,7 @@
// Sync the query to ?q= without adding a history entry, so back still leaves
// the page and a copied URL still reproduces the search.
function writeUrlQuery(q: string) {
function writeUrlQuery(q) {
const route = resolve("/[dataset]", { dataset: dataset.id });
// The target IS resolve()'d; the lint rule cannot see through the template
// literal that appends the query string.
@@ -61,7 +60,7 @@
replaceState(q ? `${route}?q=${encodeURIComponent(q)}` : route, page.state);
}
async function search(q: string) {
async function search(q) {
const source = db;
if (!source?.ready) return;
searchError = null;
@@ -104,13 +103,13 @@
// Global shortcuts: Ctrl+Enter submits the SQL query, "/" focuses the search
// box unless the user is already typing somewhere.
function onKeydown(event: KeyboardEvent) {
function onKeydown(event) {
if (event.key === "Escape" && sqlWarningOpen) {
declineSqlWarning();
return;
}
if (event.ctrlKey && event.key === "Enter" && activeTab === "sql") {
document.querySelector<HTMLFormElement>(".query-form")?.requestSubmit();
document.querySelector(".query-form")?.requestSubmit();
return;
}
if (
@@ -211,6 +210,8 @@
<footer class="mt-12 border-t border-line pt-4 text-center text-sm break-words text-ink-subtle">
<p>
Nguồn:
<!-- An off-site article URL, so there is no route for resolve() to take. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={dataset.source} target="_blank" rel="noopener noreferrer">{dataset.source}</a>
· {dataset.rows.toLocaleString("vi-VN")} thí sinh · Dữ liệu chỉ mang tính tham khảo
</p>
+1 -1
View File
@@ -7,6 +7,6 @@ export default defineConfig({
test: {
// Only the framework-free modules are unit-tested: score tiers, query-mode
// classification and the ASCII fold that has to match the Go parser.
include: ["src/lib/**/*.test.ts"],
include: ["src/lib/**/*.test.js"],
},
});