- | {row.so_bao_danh} |
+ {row.so_bao_danh} |
{row.ho_ten} |
{row.ngay_sinh || "—"} |
- {visibleColumns.map((col) => {
+ {visibleIdentity.map((col) => (
+
+ {row[col.key] || "—"}
+ |
+ ))}
+ {visibleSubjects.map((col) => {
const tier = scoreTier(row[col.key]);
const cls = tier ? `score-cell tier-${tier.key}` : "score-cell";
return (
diff --git a/src/components/search-form.jsx b/src/components/search-form.jsx
index c8e69a9..180590c 100644
--- a/src/components/search-form.jsx
+++ b/src/components/search-form.jsx
@@ -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) => (
- {i < EXAMPLES.length - 1 ? " hoặc " : ""}
+ {i < examples.length - 1 ? " hoặc " : ""}
))}
>
diff --git a/src/components/student-detail.jsx b/src/components/student-detail.jsx
index 7477513..1cdc399 100644
--- a/src/components/student-detail.jsx
+++ b/src/components/student-detail.jsx
@@ -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 }) {
{student.ngay_sinh}
)}
+ {/* Only the 2016 dataset carries these; NULL elsewhere. */}
+ {student.ten_cum_thi && (
+
+
Cụm thi
+ {student.ten_cum_thi}
+
+ )}
+ {student.gioi_tinh && (
+
+
Giới tính
+ {student.gioi_tinh}
+
+ )}
diff --git a/src/datasets.js b/src/datasets.js
index 18ed58d..96b5c73 100644
--- a/src/datasets.js
+++ b/src/datasets.js
@@ -5,31 +5,68 @@
*
* data// → parser/configs/.toml → db/.db.gz → /thptqg//
*
+ * 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,
},
];
diff --git a/src/lib/admission-blocks.js b/src/lib/admission-blocks.js
index 0119bae..15cb2f6 100644
--- a/src/lib/admission-blocks.js
+++ b/src/lib/admission-blocks.js
@@ -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
diff --git a/src/lib/query-mode.js b/src/lib/query-mode.js
new file mode 100644
index 0000000..0b7b869
--- /dev/null
+++ b/src/lib/query-mode.js
@@ -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ự` };
+}
diff --git a/src/lib/sql-presets.js b/src/lib/sql-presets.js
new file mode 100644
index 0000000..5b5661f
--- /dev/null
+++ b/src/lib/sql-presets.js
@@ -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)`,
+ },
+ ],
+ },
+];
diff --git a/src/lib/subjects.js b/src/lib/subjects.js
new file mode 100644
index 0000000..d9c9dcc
--- /dev/null
+++ b/src/lib/subjects.js
@@ -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);
+}
diff --git a/src/router.js b/src/router.js
new file mode 100644
index 0000000..d278465
--- /dev/null
+++ b/src/router.js
@@ -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;
+}