Commit Graph
72 Commits
Author SHA1 Message Date
tiennm99 c988bfafcf fix: correct the 2016 source attribution and link full article URLs
The site credited 2016 to Bộ GD&ĐT, which those files were never fetched
from. Both datasets come from published articles: 2016 from an aggregator
on dtnt.bacninh.edu.vn listing one spreadsheet per exam cluster, 2017 from
baotintuc.vn. The README, the architecture table and the web footer all
repeated the ministry claim.

The footer now shows each dataset's full article URL as a link rather than
a bare host, so the citation can be checked. That needs overflow-wrap on
the footer: the 2016 URL is 110 characters with no break opportunity and
would otherwise scroll the page sideways on a phone.

Also records that a full 2016 crawl has been run successfully. The host was
marked unconfirmed and data/2016/ described as the only recoverable copy;
both datasets are now rebuildable from source.
2026-08-14 09:26:27 +07:00
tiennm99 3995e9d864 Merge pull request #7 from tiennm99/chore/remove-js-and-dead-code
chore: remove the last JS script and the dead weight three audits found
2026-08-14 00:08:22 +07:00
tiennm99 a0420fdc37 refactor: remove the last JS script and the dead weight three audits found
The pipeline is now Go outside web/. differential-parity.mjs becomes
assembler/internal/verify, reachable as `assemble verify A B`. The port fixed a
real weakness: the JavaScript hashed each row's fields joined bare, so a value
shifted across a column boundary produced the same digest. A test now pins that.

The hub still rendered "Phiên bản cũ của trang 2017" above a permanently empty
list — it split datasets on id.includes("old"), and both such datasets are gone.
The heading and the filter are removed. index.html titled every page "THPT QG
2017", including 2016 and the hub, because one file is copied to every route;
the static title is now neutral and the app sets the dataset's own.

Dead code removed: the isOld2/containsOld branches in the stats block, which
only 2017-old2 could ever reach; SUBJECT_LABELS, DATASET_IDS and the unread
`short` subject field; an unused vite.svg and a favicon link to a file that
never existed; two unused CSS rules and --shadow-sm; site.Paths.Root.

Corrected comments that were confidently wrong rather than merely stale: the
reader claimed to be row-streaming when both implementations decode the whole
workbook into memory first, and the fidelity oracle still spoke of 299 input
files when it covers 182. Candidate counts in the hub now derive from
datasets.json instead of being written a second time as prose.

plans/ is emptied. The parity report it held was cited by docs/data-pipeline.md,
so the evidence that the recovered foreign-language scores are real — not the
citation, the four arguments themselves — is now inline there.

Verified: 2017 rebuilt after the writer change hashes identically to the build
before it.
2026-08-13 23:48:25 +07:00
tiennm99 373bfb08fe Merge pull request #6 from tiennm99/refactor/go-parser
refactor: split the repo into crawler, parser, assembler and web
2026-08-13 23:07:17 +07:00
tiennm99 08a408ca96 docs(plans): drop the completed migration plans
Both plans are done and merged, and their contents now describe a tree that no
longer exists — go-parser/, four datasets, npm entry points, a Rust crate. Git
history keeps them.

The parity evidence stays: docs/data-pipeline.md cites parser-parity-result.md
for the recovered foreign-language scores, so that report and the two JSON
snapshots behind it remain, now marked as an archived record.
2026-08-13 22:54:50 +07:00
tiennm99 c359a0b444 refactor: one directory per pipeline stage, and an assembler to drive them
The repository now reads as the pipeline it is: crawler fetches, parser
converts, assembler verifies and publishes, with data/ and web/ as the stores
they hand work through. go-parser is renamed parser now that there is no other.

The assembler replaces build-db.js and assemble-site.js. It compiles the
parser, builds and verifies each database, compresses it, runs the Vite build
and assembles _site — one command, and the only place that knows the order.

It also closes a real hole: nothing previously asserted that a database reached
the site. An empty staging directory assembled happily, so every page rendered,
every query 404d and CI stayed green. The row-count and size guards could not
catch that, since they only run when a database was built at all.

Removing Node from the root forced the dataset list out of web/src/datasets.js,
which the assembler cannot import. datasets.json is now the registry both sides
read — JSON because Go and the browser both parse it without a dependency —
while presentation stays in the web app, keyed by id and cross-checked against
the registry so a half-added dataset fails instead of half-working.

Guards verified by making each one fail: a missing database, and an expected
row count one higher than the truth.
2026-08-13 22:50:05 +07:00
tiennm99 b04f9844f9 refactor(crawler): read the file lists from the source articles
Both sources carried their download links as a hardcoded array, which is not a
crawl: the lists could drift from what the articles actually published, and
nothing would say so. A source now names the article and how to name what it
finds there, and internal/article reads the links out of that page at run time.

2016 takes its filenames straight from the URL. 2017 cannot — the CDN names are
inconsistent (Angiang.xls, 1BaRiaVungTau.xls, 23HaiPhong.xls) — so it derives
them from the province in the link text, transliterated to ASCII the same way
go-parser builds ho_ten_ascii.

Filenames stay load-bearing: go-parser sorts inputs bytewise and inserts
last-wins, so they decide which row survives a duplicate exam number. Saved
copies of both articles are committed as fixtures, and a test asserts that
reading them and applying each naming rule reproduces data/<id> exactly, in both
directions. Resolve also rejects a page that yields the wrong number of links or
two links that would write the same file, since either silently costs the
dataset files that only the row-count guard would notice afterwards.

Verified against the live 2017 article: a from-scratch crawl of all 63 files
leaves the committed data unchanged.
2026-08-13 22:05:07 +07:00
tiennm99 ceb694a747 refactor: split into web/crawler/go-parser and drop the 2017 archives
Move the frontend into web/, the repo's only npm workspace, and replace the
JS crawler with a Go module covering both remaining datasets. The crawler
writes to a .part file and renames on completion: writing straight to the
destination left truncated files that the skip-if-present check would then
skip forever.

Remove the 2017-old and 2017-old2 datasets. They were successive publications
of the same exam, kept side by side so the disagreement stayed inspectable;
the current 2017 supersedes them and they remain in git history.

Recover the 2016 crawler source from the Internet Archive's copy of the
aggregator article, whose original host no longer resolves. All 119 filenames
are verified against data/2016 in both directions, but no archive captured the
spreadsheets themselves, so the host still serving them is unconfirmed and
data/2016 remains the only confirmed copy.

Filenames are load-bearing throughout: go-parser sorts inputs bytewise and
inserts last-wins, so they decide which row survives a duplicate exam number.
2026-08-13 21:45:05 +07:00
tiennm99 8530df0154 docs(plans): record the Go parser migration outcome 2026-08-13 20:39:06 +07:00
tiennm99 00a08d5fab refactor(parser): remove the Rust crate now that Go is at parity
The Go parser has matched the Rust one field-by-field across all four datasets,
so the Rust crate is retired and CI builds the Go binary instead.

crawl-baotintuc.js moves to go-parser/scripts/ — it is the only mechanism for
refreshing data/2017 and has a documented runbook. check-duplicates.js and
diff-datasets.js are dropped: both had been broken since before the repo was
unified, and neither had a caller. db-stats.js and verify-parity.js are dropped
as superseded by differential-parity.mjs, which compares more and cannot
silently skip a dataset.

The reader-fidelity oracle is kept and marked frozen. It was produced by the
Rust reader so it can no longer be regenerated, but it still fails if any single
cell of any of the 299 input files reads differently.

Source comments cite the original Rust by file and line; those paths resolve at
tag pre-go-parser-removal, recorded in go-parser/README.md.
2026-08-13 20:38:25 +07:00
tiennm99 0eb174721d refactor(parser): reimplement the parser in Go alongside the Rust crate
Adds go-parser/, a Go reimplementation of the xlsxread parser, verified
byte-for-byte against the Rust original before any cutover.

Reader fidelity is exact across all 299 input files: the canonical cell dump
of every sheet matches calamine's, locked in as a test against a committed
hash oracle. Reaching that required replacing extrame/xls, which corrupted
69% of cells and dropped a further 28% on the BIFF corpus, with pbnjay/grate;
correcting excelize's number-format application and trailing-cell trimming;
restoring carriage returns that XML line-ending normalisation strips from
2,233 ten_cum_thi values; and gating numeric re-rendering on cell type so
shared strings that merely look numeric keep their leading zeros.

The differential gate compares both parsers over all four datasets:
3,265,641 rows with identical full-table SHA-256, identical per-column
non-NULL counts, identical schema metadata and identical stdout.

Config moves from TOML to YAML for both parsers, so they keep reading the
same files and the gate stays meaningful. Verified by rebuilding 2016 and
2017-old2 with Rust under the new configs and matching the recorded counts.

build-db.js now refuses to publish a database whose row count does not match
the known figure, closing a path where an under-producing parser could ship a
truncated public dataset with green CI. The deploy workflow gains a
pull_request trigger and guards deploy to main, so branch verification can no
longer publish to production.
2026-08-13 20:27:44 +07:00
tiennm99 8902232747 Merge pull request #5 from tiennm99/refactor/unify-frontend-and-schema
refactor: one frontend, one parser, one schema
2026-08-13 14:38:55 +07:00
tiennm99 3fcfe4862d docs: rewrite for the unified repo and record the parity gate
The docs still described two standalone projects with separate frontends,
separate parsers and three Vite variants. Rewritten around what the repo now
is, merging both projects' copies rather than keeping one and discarding the
other — deployment-guide.md and system-architecture.md existed in both and
documented different pipelines.

  project-overview.md    goal, scope, constraints, the four datasets, history
  system-architecture.md data flow, canonical schema, routing, how one
                         frontend serves both exam years without branching
  data-pipeline.md       per-dataset Excel formats, the three 2016 layouts,
                         overflow-sheet gotcha, expected row counts
  deployment-guide.md    the single-build workflow, adding a dataset,
                         why no uncompressed database can ship

Records the release gate in plans/reports/parser-parity-result.md: row counts,
all 18 pre-existing per-column non-NULL counts and the deterministic student
samples are identical across all four datasets, against databases decompressed
from the exact bytes the pipeline publishes. The 1,691 recovered
foreign-language scores are documented with the evidence they are real.

Also drops a machine-specific absolute path from a comment in
format_detect_2016.rs. The build-database.js citations there are kept: that
file no longer exists in this repo, but the references explain why several
parsing rules look arbitrary.
2026-08-13 13:25:46 +07:00
tiennm99 a5904a5d78 build: produce the whole site from one Vite build
The four build variants existed only because the app could not resolve its own
dataset. Now that it can, vite.config.js is a single build with an absolute
base and no VARIANT switching, and the workflow compiles Rust once, installs
Node dependencies once, and builds the site once — it previously built the same
Rust crate twice and ran two separate pnpm installs.

Because base is absolute, the emitted index.html references /thptqg/assets/...
regardless of where it is served from, so the same file works as an entry point
at any depth. scripts/assemble-site.js copies it to each dataset path and to
the two legacy nested URLs, giving a real static file at every published route.
That is what removes the need for an SPA 404-fallback redirect, which would
otherwise have rewritten URLs and interfered with the ?q= deep links.

Generated databases move to a gitignored .build/public, which Vite consumes as
its publicDir. build-db.js gzips without -k, and the assemble step then refuses
to finish if any uncompressed database artefact reached the output — .db,
.db-journal, .db-wal or .db-shm. Previously a raw 100+ MB database was written
into the source tree and deleted afterwards by an rm in the workflow, so
shipping one was a missing cleanup step away.

Both build scripts import DATASET_IDS from src/datasets.js rather than
repeating the dataset list in workflow shell, so the four ids are declared in
exactly one place across the frontend, the database build and the assembly.

Verified by running the full pipeline locally and serving the artifact over
HTTP: all nine routes return 200, every entry point is byte-identical, asset
references are absolute, and the databases are fetchable. The guard was tested
by injecting a raw .db and .db-journal into the build, which failed the
assembly as intended.
2026-08-13 11:57:26 +07:00
tiennm99 003e7c8afd style(frontend): document the four one-shot setState-in-effect sites
eslint-plugin-react-hooks v7 flags setState inside an effect body. All four
occurrences predate this refactor and are one-shot initialisation or external
sync, not the cascading-render pattern the rule targets:

  - hydrating a ?q= deep link once the database is ready
  - reading the candidate count for the footer
  - auto-running the schema preset when the SQL tab first opens
  - mirroring the parent-owned query into SearchForm for deep links and clear

Each gets a scoped disable with a comment explaining why it is correct here,
rather than a blanket rule change. Restructuring these properly would mean
reworking deep-link hydration and auto-search with no test harness to catch a
regression; that is worth doing separately, not inside this refactor.

npm run lint is now clean.
2026-08-13 11:48:31 +07:00
tiennm99 83bbc597ce feat(frontend): serve all four datasets and the hub from one app
The 2017 frontend becomes the only frontend. It now resolves which dataset to
show from the URL, so the four dataset views and the /thptqg/ landing page all
come from a single build instead of four builds plus a hand-written static
index.html.

Routing is an exact match on one path segment, because the segment is the
dataset id: /thptqg/2017-old/ -> "2017-old". The nested form this replaces
(/thptqg/2017/old/) would have needed longest-prefix matching, since it also
starts with /thptqg/2017/. Previously published nested URLs are rewritten to
their flat equivalent with the query string intact, so ?q= deep links keep
working.

Restores what the 2016 site had and the 2017 site lacked:

  - Cụm thi and Giới tính columns, in the table and on the detail card
  - exam-ID lookup for letter-prefixed số báo danh

That second one would have been a severe regression. 616,593 of 877,461
candidates in the 2016 dataset — 70.3% — have an SBD like BAL000001, and the
2017 app matched /^\d+$/ only, so exam-ID search would have silently failed for
most of that year. Detection now lives in lib/query-mode.js, shared by App and
SearchForm, which had already drifted apart on exactly this rule. Letter
prefixes are upper-cased before lookup so "bal000001" resolves.

Neither dataset needs a conditional in a component. Columns where every row is
NULL are hidden, so 2016 rows surface Cụm thi / Đức / Nhật while 2017 rows
surface KHTN / KHXH / GDCD / Nga. computeBlocks() likewise skips blocks with a
missing subject, so one admission-block list covers both years — D05 and D06
are added now that German and Japanese scores exist.

Per-dataset content moves into the registry: title, source, database size,
search examples and SQL presets. The 2016 presets are lifted from the deleted
2016 component; the Long An (49xxx) queries stay on 2017 only, where that SBD
prefix means something.

The subject list, previously maintained separately in score-table.jsx and
student-detail.jsx, is now declared once in lib/subjects.js mirroring
SCORE_FIELDS in the parser.
2026-08-13 11:37:59 +07:00
tiennm99 6ff2ed99ec refactor: collapse the two projects into one tree and move to npm
The repo held two near-duplicate projects. 2016/ and 2017/ each carried their
own React frontend, their own copy of the same Rust crate, and their own
package manager setup. 2016/tools/sync-from-thptqg2017.sh existed purely to
copy the parser source between them.

New layout:

  index.html + src/  the 2017 frontend, now the only one
  data/<id>/         2016, 2017, 2017-old, 2017-old2
  parser/            the single Rust crate, configs renamed to <id>.toml
  docs/              both projects' docs, 2016 copies suffixed -2016-legacy
                     pending the merge pass

<id> is now one identifier end to end: data/<id>/ feeds parser/configs/<id>.toml
and produces db/<id>.db.gz.

pnpm gives way to npm. pnpm-workspace.yaml existed only to whitelist
better-sqlite3's native build, which npm permits by default, so it has no
equivalent and is simply gone. Lockfiles cannot be converted; package-lock.json
is generated fresh. The migration direction is safe — pnpm's strict layout
forbids phantom dependencies, so anything that resolved under pnpm resolves
under npm's flat tree.

Adds parser/scripts/build-db.js and src/datasets.js: the four dataset IDs are
declared once and read by both the build tooling and (from the next phase) the
frontend.

Follow-on fixes the move made necessary:
  - eslint's Node-globals override pointed at scripts/, now parser/scripts/
  - crawl-baotintuc.js wrote to <root>/data, now data/2017
  - golden tests loaded configs by their old thptqg*-data.toml names

Drops the #[ignore]d Rust-vs-Node golden test. It shelled out to pnpm to run
scripts/build-database.js, a file removed when the parser was ported to Rust,
so it could never pass. check-duplicates.js and diff-datasets.js were already
broken before this change and are annotated as such rather than half-fixed.

63 Rust tests pass and clippy is clean from the new location.
2026-08-13 11:27:01 +07:00
tiennm99 82db8295bb style(parser): clear pre-existing clippy warnings
cargo clippy --all-targets -- -D warnings failed with 8 errors before the schema
refactor and was never part of CI. Clearing them so the gate is meaningful from
here on.

All behaviour-preserving: slice contains() over iter().any(), is_multiple_of()
over a modulo, a needless borrow in a test helper, and a doc-list indent.
process_mapped_row keeps its 8 parameters behind an allow attribute — the
signature mirrors the JS column map one-for-one, and bundling the indices into
a struct would hide that correspondence.

Kept separate from the schema change so that diff stays readable.
2026-08-13 11:16:51 +07:00
tiennm99 cd4b07f9cb refactor(parser): define one canonical student schema for all datasets
The 2016 and 2017 parsers were the same crate with divergent SQL. DDL, INSERT
and the subject regex table lived in each dataset's TOML config, so four copies
had to be kept in step by hand — which is how the two schemas drifted apart.

Move all of it into src/schema.rs as a single 22-column definition: 6 identity
columns (adding ten_cum_thi and gioi_tinh, previously 2016-only) and 16 subject
columns (the union of both exam years). Columns a dataset carries no data for
bind NULL, costing ~1 byte per row.

This collapses writer.rs to one insert path: insert_row_2016, SCORE_FIELDS_2016,
SCORE_FIELDS_2017 and the SCORE_FIELDS alias all go away. Configs shrink to the
parse rules that genuinely vary per dataset — sheet mode, column indices, SBD
validation, header tokens, blank-row stripping — and carry no SQL at all.

Config parsing now denies unknown fields, so a leftover [schema] block fails
loudly instead of looking effective while schema.rs drives the build.

idx_ten_cum_thi is partial, so it holds zero entries on the three 2017 datasets
where the column is always NULL.

Adds db-stats.js and verify-parity.js to prove no data moved: row counts,
per-column non-NULL counts and a deterministic student sample are compared
against databases built from the previous code.

Verified across all four datasets — row counts, every pre-existing column count,
and all sampled students are identical. Database size grows 1.4-2.2%.
2026-08-13 11:16:24 +07:00
tiennm99 944ee14b36 chore: merge the 2016 and 2017 lookups into one repository and one Pages site
Each year keeps its full pipeline under its directory; vite bases move to
/thptqg/<year>/ (2017 keeps its old and old2 generations), one deploy
workflow builds both years' databases and bundles behind a root index.
2026-08-13 09:36:56 +07:00
dependabot[bot] cf39bbd45c chore(deps-dev): bump vite from 8.0.12 to 8.0.16 (#3)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.12 to 8.0.16.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.16
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-25 15:27:31 +07:00
dependabot[bot] 5b3a946ad1 chore(deps-dev): bump vite from 8.0.12 to 8.0.16 (#3)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.12 to 8.0.16.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.16
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-25 15:23:46 +07:00
tiennm99 8df6f2d46b chore: remove dependabot version-update config 2026-07-25 14:13:59 +07:00
tiennm99 447796aacf chore: remove dependabot version-update config 2026-07-25 14:13:37 +07:00
tiennm99 f5d56b4273 chore: add dependabot config (#2) 2026-05-23 11:43:29 +07:00
tiennm99 79a8a73cde chore: add dependabot config (#2) 2026-05-23 11:43:25 +07:00
tiennm99 6b21c625ac feat: replace xlsx (SheetJS) build pipeline with Rust xlsxread CLI (#1)
* feat(xlsxread): vendor Rust binary cloned from thptqg2017

Copies the xlsxread Rust crate from thptqg2017@8b4a755 (chore/xlsxread-rust).
Adds format_detect_2016 module with per-file column-layout auto-detection
mirroring detectFormat() in scripts/build-database.js (lines 63-87):
  - separate-scores: SBD/HOTEN/TOAN... fixed columns (dhhanghai files)
  - mapped: header-derived SOBAODANH|SBD + DIEM_THI dynamic indices
  - default: positional 6-col layout (no header)

Extends ParsedRow with ten_cum_thi and gioi_tinh fields.
Adds SCORE_FIELDS_2016 (12 cols: tieng_duc/tieng_nhat; no khtn/khxh/tieng_nga).
Adds thptqg2016-data.toml config with 18-column schema and format_detection flag.
58 tests pass (50 unit + 8 integration), 0 failures.

* feat(build): wire build:db to xlsxread CLI

Replaces the Node.js build:db script with the xlsxread Rust binary.
Adds build:rust script for the cargo compile step in isolation.

* chore: remove deprecated build-database.js

Superseded by the xlsxread Rust binary. All 119 source files (4 .xls +
115 .xlsx) are now processed by xlsxread with per-file format detection.

* ci: build xlsxread before running database build job

Adds dtolnay/rust-toolchain@stable and Swatinem/rust-cache@v2 steps
before the xlsxread build and database generation steps. Node/pnpm
steps now follow the Rust build rather than preceding it.

* chore: add Rust build artifacts to .gitignore

* docs: update README build instructions for xlsxread pipeline

* chore(deps): drop xlsx and better-sqlite3 from package.json and lockfile
2026-05-19 16:33:20 +07:00
tiennm99 99465fda59 feat: replace xlsx (SheetJS) build pipeline with Rust xlsxread CLI (#1)
* feat(xlsxread): stage 0 scaffold with pinned deps and clap CLI skeleton

* feat(xlsxread): stage 1 reader — calamine sheet enumeration and header skip

* feat(xlsxread): stage 2 transform — to_ascii, score regex, validation with 38 unit tests

* feat(xlsxread): stage 3 writer — SQLite DDL, INSERT OR REPLACE, VACUUM, stats output

* feat(xlsxread): stage 4 audit — distinct SBD scan vs DB count, mirrors audit-row-counts.js output

* feat(xlsxread): stage 5 golden tests — in-process xlsx fixtures, 8 integration tests pass

* chore(xlsxread): commit Cargo.lock for reproducible Rust builds

* feat(build): wire root build:db scripts to xlsxread CLI

Replace node scripts/build-database*.js invocations with the Rust
xlsxread binary. Each build:db* script now calls `pnpm build:rust`
(cargo build --release) before invoking the xlsxread build subcommand
with the matching per-dataset config.

Drop xlsx and better-sqlite3 from devDependencies — no Node script
consumes them anymore. sql.js (runtime DB reader in the SPA) is
unaffected and remains in dependencies.

* ci: build xlsxread before running database build jobs

Add dtolnay/rust-toolchain@stable and Swatinem/rust-cache@v2
(workspaces: tools/xlsxread) for warm incremental Rust builds.

Replace the single `pnpm build:db:all` step with explicit xlsxread
invocations so CI doesn't call pnpm build:rust redundantly three times.
The binary is built once, then each of the three datasets is processed
in sequence.

* chore: remove deprecated xlsx-based build scripts

Delete scripts/build-database.js, build-database-old.js,
build-database-old2.js, build-lib.js, and audit-row-counts.js.
Functionality replaced by the xlsxread Rust CLI configured via
tools/xlsxread/configs/*.toml. History preserved in git; one-click
revert available via the chore/migration-backup-260519 branch.

* docs: update README build instructions for xlsxread pipeline

Replace Node.js + xlsx references with Rust + xlsxread workflow.
Update requirements (Node 24+, pnpm, Rust stable), quickstart, scripts
table, and project layout tree to reflect the current state after the
xlsx-based build scripts were removed.

* chore(deps): drop xlsx and better-sqlite3 from package.json and lockfile

Remove xlsx (SheetJS, vulnerable: GHSA-4r6h-8v6p-xvw6, GHSA-5pgg-2g8v-p4x9)
and better-sqlite3 from devDependencies. Both were only used by the now-deleted
Node build scripts. The Rust xlsxread CLI vendors SQLite via rusqlite-bundled;
no Node-side SQLite dependency is needed. `pnpm audit` returns clean.
2026-05-19 16:33:16 +07:00
tiennm99 5b0cec3b27 chore(ci): bump node to 24 2026-05-13 10:52:42 +07:00
tiennm99 b5bd88bfbb chore(ci): bump node to 24 2026-05-13 10:52:39 +07:00
tiennm99 12c41c747e fix(ci): approve better-sqlite3 build via pnpm-workspace.yaml allowBuilds 2026-05-13 10:39:20 +07:00
tiennm99 a132847cda fix(ci): approve better-sqlite3 build via pnpm-workspace.yaml allowBuilds 2026-05-13 10:39:17 +07:00
tiennm99 b3b71f577c fix(ci): bump Node.js to 22 required by pnpm@11.1.1 2026-05-13 10:34:03 +07:00
tiennm99 75201ab06a fix(ci): bump Node.js to 22 required by pnpm@11.1.1 2026-05-13 10:33:59 +07:00
tiennm99 37cee58171 fix(ci): remove pnpm version override conflicting with packageManager 2026-05-13 10:29:03 +07:00
tiennm99 8732ee138d fix(ci): remove pnpm version override conflicting with packageManager 2026-05-13 10:28:59 +07:00
tiennm99 eb7409a503 chore: migrate from npm to pnpm
Replace package-lock.json with pnpm-lock.yaml, add packageManager field,
and update GitHub Actions workflow to use pnpm/action-setup@v4 with
frozen-lockfile installs.
2026-05-13 10:20:34 +07:00
tiennm99 08e55d98b1 chore: migrate from npm to pnpm
Replace package-lock.json with pnpm-lock.yaml, add packageManager field,
update package.json scripts from npm run to pnpm, and update GitHub Actions
workflow to use pnpm/action-setup@v4 with frozen-lockfile installs.
2026-05-13 10:20:29 +07:00
tiennm99 5bdcc01f60 docs: note original data source URL (now inaccessible) 2026-04-14 23:33:15 +07:00
tiennm99 5ffc26f177 docs: note data/ source URL; mark data-old/ and data-old2/ origin unrecorded 2026-04-14 23:30:12 +07:00
tiennm99 2c7ded1f39 docs: expand README and add project documentation 2026-04-14 23:28:56 +07:00
tiennm99 76ea795910 docs: translate narrative prose to English in README and docs/ 2026-04-14 23:28:55 +07:00
tiennm99 8ca6bfa7a8 docs: expand README, add architecture / deployment / data-pipeline docs
- README: live-site URLs, features, scripts, project layout, quickstart
- docs/system-architecture.md: data flow, 3-variant deploy mechanism,
  schema, parse-quirk matrix, score-tier model, admission-block model,
  frontend key behaviors (deep-link, share, keyboard shortcuts)
- docs/data-pipeline.md: source Excel shape, score regex, why three
  parsers, overflow-sheet gotcha, audit tooling, refresh flow
- docs/deployment-guide.md: CI workflow, adding a new variant, rollback,
  GH Pages file-size note
- docs/README.md: index for the docs dir
2026-04-14 23:25:51 +07:00
tiennm99 1940e8936d tweak: Long An best-block preset → top 10 instead of top 100 2026-04-14 23:19:36 +07:00
tiennm99 c4f043ab7a feat: full 49-block admission catalog + winning-block label in top-100 query
- admission-blocks.js: expand from 8 core blocks to all 49 official 2017
  blocks (A00-A11, B00-B08, C00-C20, D01-D15) the DB schema can support.
  Detail card now shows every qualifying block, sorted best-first
- custom-query: rewrite "Top 100 Long An" preset using UNION ALL +
  ROW_NUMBER() OVER PARTITION BY so we can label each row with the
  winning block code, not just the score
2026-04-14 23:17:58 +07:00
tiennm99 a5213c8667 feat: add 'Top 100 điểm khối cao nhất - Long An' SQL preset
Computes each student's max admission-block sum across 49 official 2017
blocks (A00-A11, B00-B08, C00-C20, D01-D15, restricted to subjects
present in our DB — no Đức/Nhật languages). One row per student, sorted
by their personal best, top 100.

SQL note: SQLite's MAX(x,y,...) scalar returns NULL if ANY arg is NULL.
Each block expression is wrapped in COALESCE(..., -1); NULLIF(..., -1)
restores NULL only for students with zero computable blocks.
2026-04-14 23:11:01 +07:00
tiennm99 d1410a4729 style: TFT rarity-ladder score tiers (6 levels, unique hues)
Old palette reused gold for both "Trung bình" and "Xuất sắc" — confusing.
Replaces with League of Legends / TFT rarity ladder where each rank gets
a distinct hue that also communicates relative rarity.

Tiers:
  ≤ 1    Điểm liệt   (common)    white / gray
  < 5    Chưa đạt    (uncommon)  green
  5-6.5  Trung bình  (rare)      blue
  6.5-8  Khá         (epic)      purple
  8-9    Giỏi        (legendary) gold
  9-10   Xuất sắc    (prismatic) multi-color gradient

- admission-blocks.js: scoreTier now returns 6 keys (common..prismatic)
  plus the "điểm liệt" tier (≤1) that didn't exist before
- student-detail.jsx: TIER_LEGEND updated to 6 entries with ranges
- App.css: --tier-{common,uncommon,rare,epic,legendary,prismatic}-*
  tokens, light + dark variants each AA-contrast verified; applied to
  .score-cell, .score-tile, .tier-legend-item
2026-04-14 23:03:08 +07:00
tiennm99 1d84148bc5 tweak: reduce Long An khối A preset from top 100 to top 10 2026-04-14 22:51:48 +07:00
tiennm99 f818003c3e feat: deep-link, share, score legend, web font, grouped SQL presets
- App.jsx owns query state, syncs to URL via ?q= (works for SBD or name);
  hydrates initial search from URL on DB ready
- Loading panel now shows DB size (~47 MB) and one-time download note
- Footer reports total student count from the live DB
- Keyboard shortcut '/' focuses the search input when not already typing
- StudentDetail: "Chia sẻ" button uses Web Share API or clipboard with a
  formatted multi-line summary including a deep-link URL
- StudentDetail: inline tier legend (▽ ○ ◆ ★ ✦) with score ranges
- ScoreTable cells now use background tint matching detail tiles
- CustomQuery: presets grouped into 4 categories; auto-runs PRAGMA
  table_info(student) on first DB ready so the tab opens with the schema
  visible instead of a blank textarea
- SearchForm accepts controlled value prop for URL hydration
- Add Be Vietnam Pro web font for consistent Vietnamese diacritic rendering
2026-04-14 22:44:00 +07:00
tiennm99 8cfc9116fb feat: Long An SQL presets, clickable search examples
- custom-query: two new presets filtered by Long An (SBD prefix 49):
  Top 10 điểm Toán, Top 100 khối A (Toán+Lý+Hóa)
- search-form: show clickable example pills ("49008235" /
  "Nguyễn Minh Tiến") next to the empty-state hint; clicking fills
  the input and triggers the debounced live search
- App.css: .example-btn pill styling matching preset buttons
2026-04-14 22:21:54 +07:00