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.
This commit is contained in:
tiennm99 committed 2026-08-13 11:37:59 +07:00
1 parent 6ff2ed99ec
commit 83bbc597ce
13 files changed
+745 -265

No files matched your search

+98
View File
@@ -779,3 +779,101 @@ footer {
transition-duration: 0.01ms !important;
}
}
/* ---------------------------------------------------------------------------
Table cells for the 2016-only identity columns.
Ported from the 2016 frontend; hidden automatically on the 2017 datasets
because every row there is NULL.
--------------------------------------------------------------------------- */
.sbd-cell {
font-family: ui-monospace, monospace;
font-size: 0.85rem;
color: var(--text-muted);
white-space: nowrap;
}
.cumthi-cell {
font-size: 0.82rem;
color: var(--text-muted);
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ---------------------------------------------------------------------------
Hub route (/thptqg/) — replaces the old standalone static index.html
--------------------------------------------------------------------------- */
.hub {
max-width: 42rem;
margin: 3rem auto;
padding: 0 1rem;
line-height: 1.6;
}
.hub h1 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.hub-list {
list-style: none;
padding: 0;
margin: 1.5rem 0;
display: grid;
gap: 0.75rem;
}
.hub-link {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.25rem 0.75rem;
padding: 0.9rem 1.1rem;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface, transparent);
text-decoration: none;
color: inherit;
}
.hub-link:hover,
.hub-link:focus-visible {
border-color: var(--border-strong);
}
.hub-label {
font-weight: 600;
}
.hub-blurb {
color: var(--text-muted);
font-size: 0.9rem;
}
.hub-archive h2 {
font-size: 0.95rem;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 0;
}
.hub-list-compact {
margin-top: 0.75rem;
}
.hub-back {
margin: 0.35rem 0 0;
font-size: 0.85rem;
}
.hub-back a {
color: var(--text-muted);
text-decoration: none;
}
.hub-back a:hover {
text-decoration: underline;
}
+33 -14
View File
@@ -4,17 +4,19 @@ import { SearchForm } from "./components/search-form";
import { ScoreTable } from "./components/score-table";
import { StudentDetail } from "./components/student-detail";
import { CustomQuery } from "./components/custom-query";
import { Hub } from "./components/hub";
import { dbOf } from "./datasets";
import { resolveRoute } from "./router";
import { isExamId, normaliseExamId } from "./lib/query-mode";
import "./App.css";
const DB_URL = import.meta.env.BASE_URL + "thptqg2017.db.gz";
const MAX_RESULTS = 100;
const DB_SIZE_MB = 47;
// Strip Vietnamese diacritics: "Nguyễn Bửu Lộc" → "nguyen buu loc"
function toAscii(str) {
return str
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[̀-ͯ]/g, "")
.replace(/đ/gi, "d")
.toLowerCase();
}
@@ -38,8 +40,11 @@ function readUrlQuery() {
return new URL(window.location.href).searchParams.get("q") || "";
}
function App() {
const { db, loading, error, progress } = useSqlite(DB_URL);
/** The score-lookup view for one dataset. */
function DatasetApp({ dataset }) {
const { db, loading, error, progress } = useSqlite(
dbOf(dataset, import.meta.env.BASE_URL),
);
const [results, setResults] = useState(null);
const [searchError, setSearchError] = useState(null);
const [activeTab, setActiveTab] = useState("search");
@@ -55,14 +60,14 @@ function App() {
writeUrlQuery(q);
try {
const isExamId = /^\d+$/.test(q);
let stmt;
if (isExamId) {
if (isExamId(q)) {
// Letter-prefixed 2016 IDs are stored upper-case; digits are unaffected.
stmt = db.prepare(
"SELECT * FROM student WHERE so_bao_danh = $q LIMIT $limit",
);
stmt.bind({ $q: q, $limit: MAX_RESULTS });
stmt.bind({ $q: normaliseExamId(q), $limit: MAX_RESULTS });
} else if (isAsciiOnly(q)) {
stmt = db.prepare(
"SELECT * FROM student WHERE ho_ten_ascii LIKE $q LIMIT $limit",
@@ -141,9 +146,10 @@ function App() {
return (
<div className="app">
<header>
<h1>Tra cứu điểm thi THPT Quốc gia 2017</h1>
<p className="subtitle">
Dữ liệu thí sinh toàn quốc · Hỗ trợ truy vấn SQL tùy chỉnh
<h1>{dataset.title}</h1>
<p className="subtitle">{dataset.subtitle}</p>
<p className="hub-back">
<a href={import.meta.env.BASE_URL}>← Tất cả các kỳ thi</a>
</p>
</header>
@@ -151,7 +157,7 @@ function App() {
{loading && (
<div className="loading">
<p>
Đang tải cơ sở dữ liệu ~{DB_SIZE_MB} MB
Đang tải cơ sở dữ liệu ~{dataset.dbSizeMb} MB
{progress > 0 ? ` · ${progress}%` : ""}
</p>
<div className="progress-bar">
@@ -188,6 +194,7 @@ function App() {
onSearch={handleSearch}
onClear={handleClear}
disabled={loading || !!error}
examples={dataset.examples}
/>
{searchError && (
@@ -209,13 +216,17 @@ function App() {
)}
{activeTab === "sql" && (
<CustomQuery db={db} disabled={loading || !!error} />
<CustomQuery
db={db}
disabled={loading || !!error}
presets={dataset.presets}
/>
)}
</main>
<footer>
<p>
Nguồn: baotintuc.vn
Nguồn: {dataset.source}
{totalCount !== null && ` · ${totalCount.toLocaleString("vi-VN")} thí sinh`}
{" · Dữ liệu chỉ mang tính tham khảo"}
</p>
@@ -224,4 +235,12 @@ function App() {
);
}
function App() {
// Resolved once at mount: every route is a full page load on GitHub Pages,
// so there is no in-app navigation to react to.
const [dataset] = useState(() => resolveRoute());
return dataset ? <DatasetApp dataset={dataset} /> : <Hub />;
}
export default App;
+8 -176
View File
@@ -2,179 +2,11 @@ import { useState, useCallback, useEffect } from "react";
const MAX_ROWS = 1000;
const PRESET_GROUPS = [
{
category: "Xếp hạng môn",
queries: [
{
label: "Top 10 điểm Toán",
sql: `SELECT so_bao_danh, ho_ten, ngay_sinh, toan
FROM student WHERE toan IS NOT NULL
ORDER BY toan DESC LIMIT 10`,
},
{
label: "Top 10 KHTN",
sql: `SELECT so_bao_danh, ho_ten, vat_ly, hoa_hoc, sinh_hoc, khtn
FROM student WHERE khtn IS NOT NULL
ORDER BY khtn DESC LIMIT 10`,
},
{
label: "Top 10 KHXH",
sql: `SELECT so_bao_danh, ho_ten, lich_su, dia_ly, gdcd, khxh
FROM student WHERE khxh IS NOT NULL
ORDER BY khxh DESC LIMIT 10`,
},
{
label: "Thí sinh ≥ 9 điểm Toán",
sql: `SELECT so_bao_danh, ho_ten, ngay_sinh, toan
FROM student WHERE toan >= 9
ORDER BY toan DESC LIMIT 50`,
},
],
},
{
category: "Long An (SBD 49xxx)",
queries: [
{
label: "Top 10 Toán - Long An",
sql: `SELECT so_bao_danh, ho_ten, ngay_sinh, toan
FROM student
WHERE so_bao_danh LIKE '49%' AND toan IS NOT NULL
ORDER BY toan DESC LIMIT 10`,
},
{
label: "Top 10 khối A - Long An",
sql: `SELECT so_bao_danh, ho_ten, toan, vat_ly, hoa_hoc,
ROUND(toan + vat_ly + hoa_hoc, 2) AS tong_khoi_a
FROM student
WHERE so_bao_danh LIKE '49%'
AND toan IS NOT NULL AND vat_ly IS NOT NULL AND hoa_hoc IS NOT NULL
ORDER BY tong_khoi_a DESC LIMIT 10`,
},
{
// Top 100 Long An students by their best of 49 official 2017
// admission blocks. Each (student, block) pair is materialised via
// UNION ALL; ROW_NUMBER picks each student's #1 block; the outer
// SELECT ranks across students. Result includes the winning block
// code so the user sees which combination produced the score.
label: "Top 10 điểm khối cao nhất - Long An",
sql: `WITH per_block AS (
SELECT so_bao_danh, ho_ten, ngay_sinh, 'A00' k, toan+vat_ly+hoa_hoc s FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'A01', toan+vat_ly+tieng_anh FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'A02', toan+vat_ly+sinh_hoc FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'A03', toan+vat_ly+lich_su FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'A04', toan+vat_ly+dia_ly FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'A05', toan+hoa_hoc+lich_su FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'A06', toan+hoa_hoc+dia_ly FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'A07', toan+lich_su+dia_ly FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'A08', toan+lich_su+gdcd FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'A09', toan+dia_ly+gdcd FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'A10', toan+vat_ly+gdcd FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'A11', toan+hoa_hoc+gdcd FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'B00', toan+hoa_hoc+sinh_hoc FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'B01', toan+sinh_hoc+lich_su FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'B02', toan+sinh_hoc+dia_ly FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'B03', toan+sinh_hoc+ngu_van FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'B04', toan+sinh_hoc+gdcd FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'B08', toan+sinh_hoc+tieng_anh FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C00', ngu_van+lich_su+dia_ly FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C01', ngu_van+toan+vat_ly FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C02', ngu_van+toan+hoa_hoc FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C03', ngu_van+toan+lich_su FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C04', ngu_van+toan+dia_ly FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C05', ngu_van+vat_ly+hoa_hoc FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C06', ngu_van+vat_ly+sinh_hoc FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C07', ngu_van+vat_ly+lich_su FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C08', ngu_van+hoa_hoc+sinh_hoc FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C09', ngu_van+vat_ly+dia_ly FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C10', ngu_van+hoa_hoc+lich_su FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C12', ngu_van+sinh_hoc+lich_su FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C13', ngu_van+sinh_hoc+dia_ly FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C14', ngu_van+toan+gdcd FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C16', ngu_van+vat_ly+gdcd FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C17', ngu_van+hoa_hoc+gdcd FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C19', ngu_van+lich_su+gdcd FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C20', ngu_van+dia_ly+gdcd FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'D01', toan+ngu_van+tieng_anh FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'D02', toan+ngu_van+tieng_nga FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'D03', toan+ngu_van+tieng_phap FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'D04', toan+ngu_van+tieng_trung FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'D07', toan+hoa_hoc+tieng_anh FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'D08', toan+sinh_hoc+tieng_anh FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'D09', toan+lich_su+tieng_anh FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'D10', toan+dia_ly+tieng_anh FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'D11', ngu_van+vat_ly+tieng_anh FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'D12', ngu_van+hoa_hoc+tieng_anh FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'D13', ngu_van+sinh_hoc+tieng_anh FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'D14', ngu_van+lich_su+tieng_anh FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'D15', ngu_van+dia_ly+tieng_anh FROM student WHERE so_bao_danh LIKE '49%'
),
ranked AS (
SELECT *, ROW_NUMBER() OVER (PARTITION BY so_bao_danh ORDER BY s DESC, k) rn
FROM per_block WHERE s IS NOT NULL
)
SELECT so_bao_danh, ho_ten, ngay_sinh, k AS khoi, ROUND(s, 2) AS diem
FROM ranked WHERE rn = 1
ORDER BY diem DESC LIMIT 10`,
},
],
},
{
category: "Thống kê",
queries: [
{
label: "Phân bố điểm Toán",
sql: `SELECT
CASE
WHEN toan < 1 THEN '0-1'
WHEN toan < 2 THEN '1-2'
WHEN toan < 3 THEN '2-3'
WHEN toan < 4 THEN '3-4'
WHEN toan < 5 THEN '4-5'
WHEN toan < 6 THEN '5-6'
WHEN toan < 7 THEN '6-7'
WHEN toan < 8 THEN '7-8'
WHEN toan < 9 THEN '8-9'
ELSE '9-10'
END AS khoang_diem,
COUNT(*) AS so_luong
FROM student WHERE toan IS NOT NULL
GROUP BY khoang_diem
ORDER BY khoang_diem`,
},
{
label: "Số TS theo ngoại ngữ",
sql: `SELECT
SUM(CASE WHEN tieng_anh IS NOT NULL THEN 1 ELSE 0 END) AS tieng_anh,
SUM(CASE WHEN tieng_phap IS NOT NULL THEN 1 ELSE 0 END) AS tieng_phap,
SUM(CASE WHEN tieng_nga IS NOT NULL THEN 1 ELSE 0 END) AS tieng_nga,
SUM(CASE WHEN tieng_trung IS NOT NULL THEN 1 ELSE 0 END) AS tieng_trung
FROM student`,
},
{
label: "KHTN vs KHXH",
sql: `SELECT
SUM(CASE WHEN khtn IS NOT NULL THEN 1 ELSE 0 END) AS chon_khtn,
SUM(CASE WHEN khxh IS NOT NULL THEN 1 ELSE 0 END) AS chon_khxh,
SUM(CASE WHEN khtn IS NOT NULL AND khxh IS NOT NULL THEN 1 ELSE 0 END) AS chon_ca_hai
FROM student`,
},
],
},
{
category: "Hệ thống",
queries: [
{
label: "Schema bảng student",
sql: `PRAGMA table_info(student)`,
},
],
},
];
export function CustomQuery({ db, disabled, presets = [] }) {
// Every preset set ends with a "Hệ thống" group whose first query dumps the
// table schema; that is what gets auto-run when the tab first opens.
const schemaPreset = presets[presets.length - 1]?.queries[0];
const SCHEMA_PRESET = PRESET_GROUPS[PRESET_GROUPS.length - 1].queries[0];
export function CustomQuery({ db, disabled }) {
const [sql, setSql] = useState("");
const [columns, setColumns] = useState([]);
const [rows, setRows] = useState([]);
@@ -249,9 +81,9 @@ export function CustomQuery({ db, disabled }) {
// Auto-run the schema preset the first time the tab is opened so the user
// sees the student columns instead of a blank textarea.
useEffect(() => {
if (db && columns.length === 0 && !sql) {
executeQuery(SCHEMA_PRESET.sql);
setSql(SCHEMA_PRESET.sql);
if (db && schemaPreset && columns.length === 0 && !sql) {
executeQuery(schemaPreset.sql);
setSql(schemaPreset.sql);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [db]);
@@ -259,7 +91,7 @@ export function CustomQuery({ db, disabled }) {
return (
<div className="custom-query">
<div className="preset-groups">
{PRESET_GROUPS.map((group) => (
{presets.map((group) => (
<div key={group.category} className="preset-group">
<span className="preset-label">{group.category}</span>
<div className="preset-list">
+57
View File
@@ -0,0 +1,57 @@
import { DATASETS, pathOf } from "../datasets";
/**
* The /thptqg/ landing route.
*
* Replaces the hand-written static index.html the repo used to serve here, so
* the hub shares the app's styling and dataset list instead of repeating them.
* No database is fetched on this route.
*/
export function Hub() {
const current = DATASETS.filter((d) => !d.id.includes("old"));
const archived = DATASETS.filter((d) => d.id.includes("old"));
const base = import.meta.env.BASE_URL;
return (
<div className="hub">
<header>
<h1>Tra cứu điểm thi THPT Quốc gia</h1>
<p className="subtitle">
Tra cứu điểm thi tốt nghiệp THPT Quốc gia theo số báo danh — truy vấn
SQL chạy hoàn toàn trên trình duyệt (sql.js).
</p>
</header>
<main>
<ul className="hub-list" role="list">
{current.map((d) => (
<li key={d.id}>
<a className="hub-link" href={pathOf(d, base)}>
<span className="hub-label">{d.label}</span>
<span className="hub-blurb">{d.blurb}</span>
</a>
</li>
))}
</ul>
<section className="hub-archive" aria-labelledby="archive-heading">
<h2 id="archive-heading">Phiên bản cũ của trang 2017</h2>
<ul className="hub-list hub-list-compact" role="list">
{archived.map((d) => (
<li key={d.id}>
<a className="hub-link" href={pathOf(d, base)}>
<span className="hub-label">{d.label}</span>
<span className="hub-blurb">{d.blurb}</span>
</a>
</li>
))}
</ul>
</section>
</main>
<footer>
<p>Dữ liệu chỉ mang tính tham khảo.</p>
</footer>
</div>
);
}
+23 -23
View File
@@ -1,21 +1,5 @@
import { scoreTier } from "../lib/admission-blocks";
const SUBJECT_COLUMNS = [
{ key: "toan", label: "Toán" },
{ key: "ngu_van", label: "Ngữ văn" },
{ key: "vat_ly", label: "Vật lí" },
{ key: "hoa_hoc", label: "Hóa học" },
{ key: "sinh_hoc", label: "Sinh học" },
{ key: "khtn", label: "KHTN" },
{ key: "lich_su", label: "Lịch sử" },
{ key: "dia_ly", label: "Địa lí" },
{ key: "gdcd", label: "GDCD" },
{ key: "khxh", label: "KHXH" },
{ key: "tieng_anh", label: "Tiếng Anh" },
{ key: "tieng_phap", label: "Tiếng Pháp" },
{ key: "tieng_nga", label: "Tiếng Nga" },
{ key: "tieng_trung", label: "Tiếng Trung" },
];
import { SUBJECTS, IDENTITY_COLUMNS, hasAnyValue } from "../lib/subjects";
function formatScore(val) {
if (val === null || val === undefined) return "—";
@@ -28,10 +12,14 @@ export function ScoreTable({ results }) {
return <p className="no-results">Không tìm thấy kết quả.</p>;
}
// Hide columns where every row in the result set is null (e.g. unused foreign langs)
const visibleColumns = SUBJECT_COLUMNS.filter((col) =>
results.some((row) => row[col.key] !== null && row[col.key] !== undefined),
// Drop columns where every row in the result set is NULL. This is what makes
// one table serve both exam years: 2016 rows surface Cụm thi / GT / Đức /
// Nhật and hide KHTN / KHXH / GDCD / Nga, and 2017 rows do the reverse — with
// no dataset conditional anywhere in this component.
const visibleIdentity = IDENTITY_COLUMNS.filter((col) =>
hasAnyValue(results, col.key),
);
const visibleSubjects = SUBJECTS.filter((col) => hasAnyValue(results, col.key));
return (
<div className="table-wrapper">
@@ -42,7 +30,10 @@ export function ScoreTable({ results }) {
<th>SBD</th>
<th>Họ tên</th>
<th>Ngày sinh</th>
{visibleColumns.map((col) => (
{visibleIdentity.map((col) => (
<th key={col.key}>{col.label}</th>
))}
{visibleSubjects.map((col) => (
<th key={col.key}>{col.label}</th>
))}
</tr>
@@ -50,10 +41,19 @@ export function ScoreTable({ results }) {
<tbody>
{results.map((row) => (
<tr key={row.so_bao_danh}>
<td>{row.so_bao_danh}</td>
<td className="sbd-cell">{row.so_bao_danh}</td>
<td className="name-cell">{row.ho_ten}</td>
<td>{row.ngay_sinh || "—"}</td>
{visibleColumns.map((col) => {
{visibleIdentity.map((col) => (
<td
key={col.key}
className={col.key === "ten_cum_thi" ? "cumthi-cell" : undefined}
title={col.key === "ten_cum_thi" ? row[col.key] || "" : undefined}
>
{row[col.key] || "—"}
</td>
))}
{visibleSubjects.map((col) => {
const tier = scoreTier(row[col.key]);
const cls = tier ? `score-cell tier-${tier.key}` : "score-cell";
return (
+10 -20
View File
@@ -1,25 +1,15 @@
import { useEffect, useRef, useState } from "react";
import { detectMode } from "../lib/query-mode";
const DEBOUNCE_MS = 300;
const MIN_SBD_DIGITS = 3;
const MIN_NAME_CHARS = 2;
const EXAMPLES = ["49008235", "Nguyễn Minh Tiến"];
// Detect input mode for dynamic hint shown under the field
function detectMode(raw) {
const q = raw.trim();
if (!q) return { mode: "empty", hint: "Gõ SBD (chữ số) hoặc họ tên để tìm" };
if (/^\d+$/.test(q)) {
return q.length >= MIN_SBD_DIGITS
? { mode: "sbd", hint: `Tìm theo số báo danh · khớp chính xác` }
: { mode: "sbd-short", hint: `Cần ít nhất ${MIN_SBD_DIGITS} chữ số` };
}
return q.length >= MIN_NAME_CHARS
? { mode: "name", hint: "Tìm theo họ tên · không phân biệt dấu và hoa/thường" }
: { mode: "name-short", hint: `Cần ít nhất ${MIN_NAME_CHARS} ký tự` };
}
export function SearchForm({ value = "", onSearch, onClear, disabled }) {
export function SearchForm({
value = "",
onSearch,
onClear,
disabled,
examples = [],
}) {
const [query, setQuery] = useState(value);
const inputRef = useRef(null);
const timerRef = useRef(null);
@@ -104,7 +94,7 @@ export function SearchForm({ value = "", onSearch, onClear, disabled }) {
{mode === "empty" && (
<>
{" · VD: "}
{EXAMPLES.map((ex, i) => (
{examples.map((ex, i) => (
<span key={ex}>
<button
type="button"
@@ -116,7 +106,7 @@ export function SearchForm({ value = "", onSearch, onClear, disabled }) {
>
{ex}
</button>
{i < EXAMPLES.length - 1 ? " hoặc " : ""}
{i < examples.length - 1 ? " hoặc " : ""}
</span>
))}
</>
+17 -27
View File
@@ -1,29 +1,6 @@
import { useState } from "react";
import { computeBlocks, scoreTier } from "../lib/admission-blocks";
const SUBJECT_LABELS = {
toan: "Toán",
ngu_van: "Ngữ văn",
vat_ly: "Vật lí",
hoa_hoc: "Hóa học",
sinh_hoc: "Sinh học",
khtn: "KHTN",
lich_su: "Lịch sử",
dia_ly: "Địa lí",
gdcd: "GDCD",
khxh: "KHXH",
tieng_anh: "Tiếng Anh",
tieng_phap: "Tiếng Pháp",
tieng_nga: "Tiếng Nga",
tieng_trung: "Tiếng Trung",
};
const SUBJECT_ORDER = [
"toan", "ngu_van",
"vat_ly", "hoa_hoc", "sinh_hoc", "khtn",
"lich_su", "dia_ly", "gdcd", "khxh",
"tieng_anh", "tieng_phap", "tieng_nga", "tieng_trung",
];
import { SUBJECTS } from "../lib/subjects";
function fmt(n) {
return n === null || n === undefined ? "—" : Number(n).toFixed(2);
@@ -43,9 +20,9 @@ const TIER_LEGEND = [
export function StudentDetail({ student }) {
const [copied, setCopied] = useState(null); // null | 'sbd' | 'share' | 'url'
const blocks = computeBlocks(student);
const subjects = SUBJECT_ORDER
.filter((k) => student[k] !== null && student[k] !== undefined)
.map((k) => ({ key: k, label: SUBJECT_LABELS[k], score: student[k] }));
const subjects = SUBJECTS
.filter((s) => student[s.key] !== null && student[s.key] !== undefined)
.map((s) => ({ key: s.key, label: s.label, score: student[s.key] }));
function flash(kind) {
setCopied(kind);
@@ -108,6 +85,19 @@ export function StudentDetail({ student }) {
<dd className="mono">{student.ngay_sinh}</dd>
</div>
)}
{/* Only the 2016 dataset carries these; NULL elsewhere. */}
{student.ten_cum_thi && (
<div>
<dt>Cụm thi</dt>
<dd>{student.ten_cum_thi}</dd>
</div>
)}
{student.gioi_tinh && (
<div>
<dt>Giới tính</dt>
<dd>{student.gioi_tinh}</dd>
</div>
)}
</dl>
</div>
<div className="detail-actions">
+37
View File
@@ -5,31 +5,68 @@
*
* data/<id>/ → parser/configs/<id>.toml → db/<id>.db.gz → /thptqg/<id>/
*
* Site path and database URL are derived from `id` rather than stored, so a
* dataset cannot be misconfigured into pointing at the wrong database.
*
* Imported by the Vite app *and* by parser/scripts/build-db.js under plain
* Node, so this module must stay free of `import.meta.env` and any Vite-only
* syntax. Callers pass the base URL in explicitly for that reason.
*/
// Extension is required: this module is also imported by plain Node
// (parser/scripts/build-db.js), which does not resolve extensionless paths.
import { PRESETS_2016, PRESETS_2017 } from "./lib/sql-presets.js";
const SUBTITLE = "Dữ liệu thí sinh toàn quốc · Hỗ trợ truy vấn SQL tùy chỉnh";
export const DATASETS = [
{
id: "2016",
year: 2016,
label: "Kỳ thi 2016",
blurb: "877.461 thí sinh",
title: "Tra cứu điểm thi THPT Quốc gia 2016",
subtitle: SUBTITLE,
source: "Bộ GD&ĐT",
dbSizeMb: 44,
examples: ["17006021", "Nguyễn Thị Hoa"],
presets: PRESETS_2016,
},
{
id: "2017",
year: 2017,
label: "Kỳ thi 2017",
blurb: "861.068 thí sinh",
title: "Tra cứu điểm thi THPT Quốc gia 2017",
subtitle: SUBTITLE,
source: "baotintuc.vn",
dbSizeMb: 48,
examples: ["49008235", "Nguyễn Minh Tiến"],
presets: PRESETS_2017,
},
{
id: "2017-old",
year: 2017,
label: "2017 — bản cũ",
blurb: "847.348 thí sinh · bản trước khi cập nhật",
title: "Tra cứu điểm thi THPT Quốc gia 2017 (bản cũ)",
subtitle: SUBTITLE,
source: "bản lưu trước đợt cập nhật baotintuc.vn",
dbSizeMb: 47,
examples: ["49008235", "Nguyễn Minh Tiến"],
presets: PRESETS_2017,
},
{
id: "2017-old2",
year: 2017,
label: "2017 — bản cũ 2",
blurb: "679.764 thí sinh · bản xuất lại",
title: "Tra cứu điểm thi THPT Quốc gia 2017 (bản cũ 2)",
subtitle: SUBTITLE,
source: "bản xuất lại đã hiệu chỉnh",
dbSizeMb: 38,
examples: ["49008235", "Nguyễn Minh Tiến"],
presets: PRESETS_2017,
},
];
+16 -5
View File
@@ -1,10 +1,13 @@
// Vietnamese university admission-block ("khối thi") definitions for 2017.
// Vietnamese university admission-block ("khối thi") definitions.
// Each block is the sum of 3 subjects; we only compute a block when ALL three
// subject scores exist for the student.
// Full 2017 admission-block list (Circular 03/2017/TT-BGDĐT) restricted to
// the 12 subjects available in our DB. Blocks needing Tiếng Đức / Nhật are
// excluded — neither language appears in any source file.
//
// One list serves both exam years. computeBlocks() skips any block with a
// missing subject score, so blocks needing GDCD self-exclude on 2016 rows and
// blocks needing Tiếng Đức / Nhật self-exclude wherever those weren't sat —
// no per-year branching required.
//
// Based on Circular 03/2017/TT-BGDĐT.
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" },
@@ -55,6 +58,14 @@ export const ADMISSION_BLOCKS = [
{ code: "D13", subjects: ["ngu_van", "sinh_hoc", "tieng_anh"], label: "Văn + Sinh + Anh" },
{ code: "D14", subjects: ["ngu_van", "lich_su", "tieng_anh"], label: "Văn + Sử + Anh" },
{ code: "D15", subjects: ["ngu_van", "dia_ly", "tieng_anh"], label: "Văn + Địa + Anh" },
// German and Japanese blocks. Previously omitted because neither language
// appeared in the 2017 database — but that was a parser gap, not reality:
// unifying the subject patterns recovered German and Japanese scores in every
// dataset. Students who sat neither are unaffected, since computeBlocks()
// skips blocks with a missing subject.
{ code: "D05", subjects: ["toan", "ngu_van", "tieng_duc"], label: "Toán + Văn + Đức" },
{ code: "D06", subjects: ["toan", "ngu_van", "tieng_nhat"], label: "Toán + Văn + Nhật" },
];
// Returns { code, label, total, parts:[{key,score}] } for every block where
+59
View File
@@ -0,0 +1,59 @@
/**
* Classify a search box query as an exam ID (số báo danh) or a name.
*
* Shared by App.jsx and search-form.jsx. They previously carried separate
* copies of this rule and had already drifted: the 2016 app matched
* `/^[A-Z]{2,4}\d+$/i || /^\d+$/` while the 2017 app matched only `/^\d+$/`.
*
* SBD formats across both exam years:
*
* 49008235 2017 — 8 digits, first two identify the province
* 017006021 2016 — 9 digits including a leading zero
* BAL000001 2016 — 2-4 letter exam-cluster code then digits
*
* That last form is not an edge case: 616,593 of 877,461 candidates in the
* 2016 dataset (70.3%) have a letter-prefixed SBD. Matching digits only would
* break exam-ID lookup for most of that year.
*/
const SBD_PATTERN = /^[A-Za-z]{0,4}\d+$/;
export const MIN_SBD_DIGITS = 3;
export const MIN_NAME_CHARS = 2;
/** True when the query looks like an exam ID rather than a name. */
export function isExamId(query) {
return SBD_PATTERN.test(query.trim());
}
/**
* 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) {
return query.trim().toUpperCase();
}
function digitCount(str) {
return (str.match(/\d/g) ?? []).length;
}
/**
* Decide what the user is searching for, and what hint to show beneath the
* field. Returns `{ mode, hint }` where mode is one of:
* empty | sbd | sbd-short | name | name-short.
*/
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" };
if (isExamId(q)) {
return digitCount(q) >= MIN_SBD_DIGITS
? { mode: "sbd", hint: "Tìm theo số báo danh · khớp chính xác" }
: { mode: "sbd-short", hint: `Cần ít nhất ${MIN_SBD_DIGITS} chữ số` };
}
return q.length >= MIN_NAME_CHARS
? { mode: "name", hint: "Tìm theo họ tên · không phân biệt dấu và hoa/thường" }
: { mode: "name-short", hint: `Cần ít nhất ${MIN_NAME_CHARS} ký tự` };
}
+280
View File
@@ -0,0 +1,280 @@
/**
* SQL presets shown in the "Truy vấn SQL" tab, per dataset.
*
* These are genuinely dataset-specific and cannot be shared:
* - 2016 has ten_cum_thi / gioi_tinh to group by, and no KHTN/KHXH/GDCD
* - 2017 has the composite KHTN/KHXH scores, and its SBDs are province-
* prefixed 8-digit numbers, which the Long An (49xxx) queries rely on
*
* Both end with a "Hệ thống" group whose first query is run automatically when
* the SQL tab is first opened, so the schema is visible before writing a query.
*/
export const PRESETS_2017 = [
{
category: "Xếp hạng môn",
queries: [
{
label: "Top 10 điểm Toán",
sql: `SELECT so_bao_danh, ho_ten, ngay_sinh, toan
FROM student WHERE toan IS NOT NULL
ORDER BY toan DESC LIMIT 10`,
},
{
label: "Top 10 KHTN",
sql: `SELECT so_bao_danh, ho_ten, vat_ly, hoa_hoc, sinh_hoc, khtn
FROM student WHERE khtn IS NOT NULL
ORDER BY khtn DESC LIMIT 10`,
},
{
label: "Top 10 KHXH",
sql: `SELECT so_bao_danh, ho_ten, lich_su, dia_ly, gdcd, khxh
FROM student WHERE khxh IS NOT NULL
ORDER BY khxh DESC LIMIT 10`,
},
{
label: "Thí sinh ≥ 9 điểm Toán",
sql: `SELECT so_bao_danh, ho_ten, ngay_sinh, toan
FROM student WHERE toan >= 9
ORDER BY toan DESC LIMIT 50`,
},
],
},
{
category: "Long An (SBD 49xxx)",
queries: [
{
label: "Top 10 Toán - Long An",
sql: `SELECT so_bao_danh, ho_ten, ngay_sinh, toan
FROM student
WHERE so_bao_danh LIKE '49%' AND toan IS NOT NULL
ORDER BY toan DESC LIMIT 10`,
},
{
label: "Top 10 khối A - Long An",
sql: `SELECT so_bao_danh, ho_ten, toan, vat_ly, hoa_hoc,
ROUND(toan + vat_ly + hoa_hoc, 2) AS tong_khoi_a
FROM student
WHERE so_bao_danh LIKE '49%'
AND toan IS NOT NULL AND vat_ly IS NOT NULL AND hoa_hoc IS NOT NULL
ORDER BY tong_khoi_a DESC LIMIT 10`,
},
{
// Top 100 Long An students by their best of 49 official 2017
// admission blocks. Each (student, block) pair is materialised via
// UNION ALL; ROW_NUMBER picks each student's #1 block; the outer
// SELECT ranks across students. Result includes the winning block
// code so the user sees which combination produced the score.
label: "Top 10 điểm khối cao nhất - Long An",
sql: `WITH per_block AS (
SELECT so_bao_danh, ho_ten, ngay_sinh, 'A00' k, toan+vat_ly+hoa_hoc s FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'A01', toan+vat_ly+tieng_anh FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'A02', toan+vat_ly+sinh_hoc FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'A03', toan+vat_ly+lich_su FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'A04', toan+vat_ly+dia_ly FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'A05', toan+hoa_hoc+lich_su FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'A06', toan+hoa_hoc+dia_ly FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'A07', toan+lich_su+dia_ly FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'A08', toan+lich_su+gdcd FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'A09', toan+dia_ly+gdcd FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'A10', toan+vat_ly+gdcd FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'A11', toan+hoa_hoc+gdcd FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'B00', toan+hoa_hoc+sinh_hoc FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'B01', toan+sinh_hoc+lich_su FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'B02', toan+sinh_hoc+dia_ly FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'B03', toan+sinh_hoc+ngu_van FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'B04', toan+sinh_hoc+gdcd FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'B08', toan+sinh_hoc+tieng_anh FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C00', ngu_van+lich_su+dia_ly FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C01', ngu_van+toan+vat_ly FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C02', ngu_van+toan+hoa_hoc FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C03', ngu_van+toan+lich_su FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C04', ngu_van+toan+dia_ly FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C05', ngu_van+vat_ly+hoa_hoc FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C06', ngu_van+vat_ly+sinh_hoc FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C07', ngu_van+vat_ly+lich_su FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C08', ngu_van+hoa_hoc+sinh_hoc FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C09', ngu_van+vat_ly+dia_ly FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C10', ngu_van+hoa_hoc+lich_su FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C12', ngu_van+sinh_hoc+lich_su FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C13', ngu_van+sinh_hoc+dia_ly FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C14', ngu_van+toan+gdcd FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C16', ngu_van+vat_ly+gdcd FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C17', ngu_van+hoa_hoc+gdcd FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C19', ngu_van+lich_su+gdcd FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'C20', ngu_van+dia_ly+gdcd FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'D01', toan+ngu_van+tieng_anh FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'D02', toan+ngu_van+tieng_nga FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'D03', toan+ngu_van+tieng_phap FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'D04', toan+ngu_van+tieng_trung FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'D07', toan+hoa_hoc+tieng_anh FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'D08', toan+sinh_hoc+tieng_anh FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'D09', toan+lich_su+tieng_anh FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'D10', toan+dia_ly+tieng_anh FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'D11', ngu_van+vat_ly+tieng_anh FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'D12', ngu_van+hoa_hoc+tieng_anh FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'D13', ngu_van+sinh_hoc+tieng_anh FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'D14', ngu_van+lich_su+tieng_anh FROM student WHERE so_bao_danh LIKE '49%'
UNION ALL SELECT so_bao_danh, ho_ten, ngay_sinh, 'D15', ngu_van+dia_ly+tieng_anh FROM student WHERE so_bao_danh LIKE '49%'
),
ranked AS (
SELECT *, ROW_NUMBER() OVER (PARTITION BY so_bao_danh ORDER BY s DESC, k) rn
FROM per_block WHERE s IS NOT NULL
)
SELECT so_bao_danh, ho_ten, ngay_sinh, k AS khoi, ROUND(s, 2) AS diem
FROM ranked WHERE rn = 1
ORDER BY diem DESC LIMIT 10`,
},
],
},
{
category: "Thống kê",
queries: [
{
label: "Phân bố điểm Toán",
sql: `SELECT
CASE
WHEN toan < 1 THEN '0-1'
WHEN toan < 2 THEN '1-2'
WHEN toan < 3 THEN '2-3'
WHEN toan < 4 THEN '3-4'
WHEN toan < 5 THEN '4-5'
WHEN toan < 6 THEN '5-6'
WHEN toan < 7 THEN '6-7'
WHEN toan < 8 THEN '7-8'
WHEN toan < 9 THEN '8-9'
ELSE '9-10'
END AS khoang_diem,
COUNT(*) AS so_luong
FROM student WHERE toan IS NOT NULL
GROUP BY khoang_diem
ORDER BY khoang_diem`,
},
{
label: "Số TS theo ngoại ngữ",
sql: `SELECT
SUM(CASE WHEN tieng_anh IS NOT NULL THEN 1 ELSE 0 END) AS tieng_anh,
SUM(CASE WHEN tieng_phap IS NOT NULL THEN 1 ELSE 0 END) AS tieng_phap,
SUM(CASE WHEN tieng_nga IS NOT NULL THEN 1 ELSE 0 END) AS tieng_nga,
SUM(CASE WHEN tieng_trung IS NOT NULL THEN 1 ELSE 0 END) AS tieng_trung
FROM student`,
},
{
label: "KHTN vs KHXH",
sql: `SELECT
SUM(CASE WHEN khtn IS NOT NULL THEN 1 ELSE 0 END) AS chon_khtn,
SUM(CASE WHEN khxh IS NOT NULL THEN 1 ELSE 0 END) AS chon_khxh,
SUM(CASE WHEN khtn IS NOT NULL AND khxh IS NOT NULL THEN 1 ELSE 0 END) AS chon_ca_hai
FROM student`,
},
],
},
{
category: "Hệ thống",
queries: [
{
label: "Schema bảng student",
sql: `PRAGMA table_info(student)`,
},
],
},
];
export const PRESETS_2016 = [
{
category: "Xếp hạng môn",
queries: [
{
label: "Top 10 điểm Toán",
sql: `SELECT so_bao_danh, ho_ten, ten_cum_thi, toan
FROM student WHERE toan IS NOT NULL
ORDER BY toan DESC LIMIT 10`,
},
{
label: "Thí sinh ≥ 9 điểm Toán",
sql: `SELECT so_bao_danh, ho_ten, ten_cum_thi, gioi_tinh, toan
FROM student WHERE toan >= 9
ORDER BY toan DESC LIMIT 50`,
},
{
label: "Top 10 khối A",
sql: `SELECT so_bao_danh, ho_ten, ten_cum_thi, toan, vat_ly, hoa_hoc,
ROUND(toan + vat_ly + hoa_hoc, 2) AS tong_khoi_a
FROM student
WHERE toan IS NOT NULL AND vat_ly IS NOT NULL AND hoa_hoc IS NOT NULL
ORDER BY tong_khoi_a DESC LIMIT 10`,
},
],
},
{
category: "Cụm thi & giới tính",
queries: [
{
label: "Điểm trung bình theo cụm thi",
sql: `SELECT ten_cum_thi,
COUNT(*) AS so_luong,
ROUND(AVG(toan), 2) AS tb_toan,
ROUND(AVG(ngu_van), 2) AS tb_van,
ROUND(AVG(tieng_anh), 2) AS tb_anh
FROM student
GROUP BY ten_cum_thi
ORDER BY so_luong DESC
LIMIT 20`,
},
{
label: "Thống kê theo giới tính",
sql: `SELECT gioi_tinh,
COUNT(*) AS so_luong,
ROUND(AVG(toan), 2) AS tb_toan,
ROUND(AVG(ngu_van), 2) AS tb_van
FROM student
WHERE gioi_tinh IS NOT NULL
GROUP BY gioi_tinh`,
},
],
},
{
category: "Thống kê",
queries: [
{
label: "Phân bố điểm Toán",
sql: `SELECT
CASE
WHEN toan < 1 THEN '0-1'
WHEN toan < 2 THEN '1-2'
WHEN toan < 3 THEN '2-3'
WHEN toan < 4 THEN '3-4'
WHEN toan < 5 THEN '4-5'
WHEN toan < 6 THEN '5-6'
WHEN toan < 7 THEN '6-7'
WHEN toan < 8 THEN '7-8'
WHEN toan < 9 THEN '8-9'
ELSE '9-10'
END AS khoang_diem,
COUNT(*) AS so_luong
FROM student WHERE toan IS NOT NULL
GROUP BY khoang_diem
ORDER BY khoang_diem`,
},
{
label: "Số thí sinh theo ngoại ngữ",
sql: `SELECT
SUM(CASE WHEN tieng_anh IS NOT NULL THEN 1 ELSE 0 END) AS tieng_anh,
SUM(CASE WHEN tieng_phap IS NOT NULL THEN 1 ELSE 0 END) AS tieng_phap,
SUM(CASE WHEN tieng_nga IS NOT NULL THEN 1 ELSE 0 END) AS tieng_nga,
SUM(CASE WHEN tieng_duc IS NOT NULL THEN 1 ELSE 0 END) AS tieng_duc,
SUM(CASE WHEN tieng_nhat IS NOT NULL THEN 1 ELSE 0 END) AS tieng_nhat,
SUM(CASE WHEN tieng_trung IS NOT NULL THEN 1 ELSE 0 END) AS tieng_trung
FROM student`,
},
],
},
{
category: "Hệ thống",
queries: [
{
label: "Schema bảng student",
sql: `PRAGMA table_info(student)`,
},
],
},
];
+51
View File
@@ -0,0 +1,51 @@
/**
* The 16 subject columns of the canonical schema, in display order.
*
* Mirrors `SCORE_FIELDS` in parser/src/schema.rs. Previously this list was
* maintained separately in score-table.jsx and student-detail.jsx, which is how
* they drifted out of sync with each other and with the database.
*
* No subject is dataset-specific here. Columns a given exam year has no data
* for are simply NULL for every row, and the UI hides all-NULL columns — so one
* list serves 2016 and 2017 without branching.
*/
export const SUBJECTS = [
{ key: "toan", label: "Toán", short: "Toán" },
{ key: "ngu_van", label: "Ngữ văn", short: "Văn" },
{ key: "vat_ly", label: "Vật lí", short: "Lý" },
{ key: "hoa_hoc", label: "Hóa học", short: "Hóa" },
{ key: "sinh_hoc", label: "Sinh học", short: "Sinh" },
{ key: "khtn", label: "KHTN", short: "KHTN" },
{ key: "lich_su", label: "Lịch sử", short: "Sử" },
{ key: "dia_ly", label: "Địa lí", short: "Địa" },
{ key: "gdcd", label: "GDCD", short: "GDCD" },
{ key: "khxh", label: "KHXH", short: "KHXH" },
{ key: "tieng_anh", label: "Tiếng Anh", short: "T.Anh" },
{ key: "tieng_phap", label: "Tiếng Pháp", short: "T.Pháp" },
{ key: "tieng_nga", label: "Tiếng Nga", short: "T.Nga" },
{ key: "tieng_duc", label: "Tiếng Đức", short: "T.Đức" },
{ key: "tieng_nhat", label: "Tiếng Nhật", short: "T.Nhật" },
{ key: "tieng_trung", label: "Tiếng Trung", short: "T.Trung" },
];
/**
* Non-score columns worth showing in the results table.
*
* Only the 2016 dataset populates these; they are NULL throughout the 2017
* datasets and get hidden by the same all-NULL filter that hides unused
* subjects, so no per-dataset conditional is needed.
*/
export const IDENTITY_COLUMNS = [
{ key: "ten_cum_thi", label: "Cụm thi" },
{ key: "gioi_tinh", label: "GT" },
];
export const SUBJECT_LABELS = Object.fromEntries(
SUBJECTS.map((s) => [s.key, s.label]),
);
/** True when at least one row carries a value for `key`. */
export function hasAnyValue(rows, key) {
return rows.some((row) => row[key] !== null && row[key] !== undefined);
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Path → dataset resolution.
*
* URLs are flat, one segment per dataset, and the segment IS the dataset id:
*
* /thptqg/ → null (hub)
* /thptqg/2016/ → 2016
* /thptqg/2017-old/ → 2017-old
*
* That makes this an exact match on a single segment. The nested form this
* replaced (/thptqg/2017/old/) needed longest-prefix matching, because
* "/thptqg/2017/old/" also starts with "/thptqg/2017/" — an entire class of
* routing bug the flat scheme designs out.
*/
import { DATASETS } from "./datasets";
/**
* URLs published before the switch to flat paths. Kept working so existing
* links and bookmarks resolve instead of 404ing.
*/
const LEGACY_PATHS = {
"2017/old": "2017-old",
"2017/old2": "2017-old2",
};
const BASE = import.meta.env.BASE_URL;
/** Strip the site base and any trailing slash, leaving a bare route segment. */
function toSegment(pathname) {
const path = pathname.startsWith(BASE) ? pathname.slice(BASE.length) : pathname;
return path.replace(/^\/+/, "").replace(/\/+$/, "");
}
/**
* Resolve the current location to a dataset, or null for the hub.
*
* Rewrites legacy nested URLs to their flat equivalent, preserving the query
* string — a `?q=` deep link must survive the redirect, which is exactly what
* the usual SPA 404-fallback hack would break.
*/
export function resolveRoute(pathname = window.location.pathname) {
let segment = toSegment(pathname);
const flat = LEGACY_PATHS[segment];
if (flat) {
window.history.replaceState(
{},
"",
`${BASE}${flat}/${window.location.search}${window.location.hash}`,
);
segment = flat;
}
return DATASETS.find((d) => d.id === segment) ?? null;
}