Skip to main content

HƯỚNG DẪN TẠO APP TRỘN ĐỀ TRẮC NGHIỆM BẰNG GOOGLE APP SCIPT

I. Benefits

  • Tạo bộ đề thi nhanh và khác nhau đối với từng người tham gia thi
  • Tiết kiệm thời gian và công sức trong việc chuẩn bị đề thi
  • Tùy chỉnh linh hoạt theo nhu cầu số lượng câu hỏi và câu trả lời
  • Theo dõi tiến độ làm bài và kết quả chi tiết của từng thí sinh
  • Ngăn chặn gian lận do mỗi người nhận được đề thi khác nhau

II. Prerequisites

  • Có tài khoản Google (Gmail)
  • Drive chứa dự án cần setup ở chế độ công khai (Anyone with the link can view)
  • Quyền truy cập Google Apps Script
  • Biết cách sử dụng chức năng Google App Script cơ bản. Tham khảo TẠI ĐÂY
  • Biết cách lấy ID Google Sheet. Tham khảo TẠI ĐÂY

III. Tutorials

Random Exam Generator

  • Mô tả ngắn: đây là công cụ trộn câu hỏi từ ngân hàng câu hỏi input vào để tạo thành những bộ đề kiềm tra riêng biệt đối với từng người tham gia kiểm tra.

  • Cấu tạo: gồm 2 phần

    • Mã nguồn: bộ mã thực thi chính. Có thể điều chỉnh số lượng câu hỏi cho mỗi bài thi, số lượng đáp án tối thiểu và tối đa cho từng câu hỏi mỗi bộ đề.
    • Sheet nội dung: gồm sheet chứa ngân hàng câu hỏi và sheet chứa câu trả lời.
  • Cách hoạt động:

    • Khi triển khai app, app sẽ tự động lấy câu hỏi từ sheet ngân hàng câu hỏi trộn lại, cứ mỗi lần có người đăng nhập vào làm bài là mỗi lần trộn tạo ra bộ đề mới.
    • Sheet ngân hàng câu hỏi - một sheet lớn có thể tạo nhiều trang tính nhỏ, mỗi trang tính chứa bộ câu hỏi về một nội dung riêng biệt, khi triển khai thì app tự động tạo ra số lượng nội dung kiểm tra (còn gọi là bộ đề) tương ứng với số lượng trang tính có trong sheet
    • Sheet câu trả lời ghi nhận thông tin phản hồi của người tham gia kiểm tra
  • Các bước triển khai

B1: Đăng nhập vào google drive

B2: Tạo 1 folder để ở chế độ công khai

B3: Tạo sheet chứa ngân hàng câu hỏi theo cấu trúc sau:

CÂU HỎIĐÁP ÁN ĐÚNGĐÁP ÁN SAI
Câu 1Đáp án đúng 1Đáp án đúng 2Đáp án đúng 3Đáp án đúng 4Tất cả đều đúngTất cả đều saiĐáp án sai 1Đáp án sai 2Đáp án sai 3Đáp án sai 4
Câu 2Đáp án đúng 1Đáp án đúng 2Đáp án đúng 3Đáp án đúng 4Tất cả đều đúngTất cả đều saiĐáp án sai 1Đáp án sai 2Đáp án sai 3Đáp án sai 4
Câu 3Đáp án đúng 1Đáp án đúng 2Đáp án đúng 3Đáp án đúng 4Tất cả đều đúngTất cả đều saiĐáp án sai 1Đáp án sai 2Đáp án sai 3Đáp án sai 4
Câu 4Đáp án đúng 1Đáp án đúng 2Đáp án đúng 3Đáp án đúng 4Tất cả đều đúngTất cả đều saiĐáp án sai 1Đáp án sai 2Đáp án sai 3Đáp án sai 4
Câu 5Đáp án đúng 1Đáp án đúng 2Đáp án đúng 3Đáp án đúng 4Tất cả đều đúngTất cả đều saiĐáp án sai 1Đáp án sai 2Đáp án sai 3Đáp án sai 4

Khi tạo trên google sheet, có thể merge lại nhóm header của các đáp áp đúng và sai. Số lượng đáp án đúng, đáp án sai và câu hỏi tùy chỉnh tương ứng với các biến trong bộ code. Đáp án đúng và đáp án sai sẽ được nhóm lại về hai phía riêng biệt trong sheet câu hỏi và nằm dưới header đáp án đúng và đáp án sai.

B4: Tạo sheet trống để chứa câu trả lời

Tạo file câu hỏi và trả lời câu hỏi

B5: Ở sheet ngân hàng câu hỏi => chọn Tiện ích => chọn Appscript

Mở app script Trong giao diện Appscript

1/ Tạo file mã thực thi là code.gs sau đó dán đoạn mã sau vào file => bấm Save

📄 Google Apps Script (Click để xem/ẩn)
// ========== QUIZ CONFIGURATION - DỄ DÀNG TÙY CHỈNH ==========

const CONFIG = {
QUESTIONS_SHEET_ID: "thay bằng id sheet ngân hàng câu hỏi", // Sheet ngân hàng câu hỏi
RESULTS_SHEET_ID: "thay bằng id sheet câu trả lời", // Sheet câu trả lời
QUESTIONS_PER_QUIZ: 5, // Điều chỉnh số câu hỏi của bài kiểm tra bằng cách thay đổi giá trị ở biến này. Ví dụ, biến đang có giá trị là 5, tương đuong có 5 câu hỏi
MIN_OPTIONS_PER_QUESTION: 4, // Thay đổi số đáp án tối thiểu hiển thị bằng cách điều chỉnh giá trị biến này. Ví dụ, biến đang có giá trị là 4, tương đương số câu hỏi tối thiểu là 4
MAX_OPTIONS_PER_QUESTION: 8, // Thay đổi số đáp án tối đa hiển thị bằng cách điều chỉnh giá trị biến này. Ví dụ, biến đang có giá trị là 8, tương đương số câu hỏi tối đa là 8

// === CẤU HÌNH TIMER ===
QUIZ_TIME_MINUTES: 5, // Cấu hình thời gian làm bài (phút). Ví dụ, biến đang có giá trị là 5, tương đương thời gian làm bài 5 phút
QUIZ_TIME_SECONDS: 5 * 60, // Hoặc tính bằng giây (tự động sync)

// === CẤU HÌNH CẢNH BÁO ===
WARNING_TIMES: {
YELLOW: 3, // Chuyển màu vàng khi còn 5 phút
ORANGE: 2, // Chuyển màu cam khi còn 3 phút
RED: 1, // Chuyển màu đỏ khi còn 1 phút
},

// === CẤU HÌNH HÀNH VI ===
AUTO_SUBMIT_ON_TIMEOUT: true, // Tự động nộp bài khi hết giờ
SHOW_TIME_USED_IN_RESULT: true, // Hiển thị thời gian đã sử dụng
SAVE_TIME_TO_SHEET: true, // Lưu thời gian vào Google Sheet

// === CẤU HÌNH HIỂN THỊ ===
SHOW_MILLISECONDS: false, // Hiển thị mili giây (không khuyến khích)
TIMER_UPDATE_INTERVAL: 1000, // Cập nhật mỗi 1000ms = 1 giây
};

// Hàm helper để lấy config (gọi từ client-side)
function getQuizConfig() {
return {
quizTimeMinutes: CONFIG.QUIZ_TIME_MINUTES,
quizTimeSeconds: CONFIG.QUIZ_TIME_SECONDS,
warningTimes: CONFIG.WARNING_TIMES,
autoSubmit: CONFIG.AUTO_SUBMIT_ON_TIMEOUT,
showTimeUsed: CONFIG.SHOW_TIME_USED_IN_RESULT,
timerUpdateInterval: CONFIG.TIMER_UPDATE_INTERVAL,
};
}

// ========== CÁC HÀM GỐC ==========

function doGet() {
return HtmlService.createTemplateFromFile("index")
.evaluate()
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}

function include(filename) {
return HtmlService.createHtmlOutputFromFile(filename).getContent();
}

function getAvailableSubjects(userEmail) {
try {
const questionsFile = SpreadsheetApp.openById(CONFIG.QUESTIONS_SHEET_ID);
const sheets = questionsFile.getSheets();

const allSubjects = sheets.map((sheet) => ({
code: sheet.getName(),
name: sheet.getName(),
displayName: `Bộ đề ${sheet.getName()}`,
}));

if (!userEmail) {
return allSubjects;
}

const completedSubjects = getCompletedSubjects(userEmail);
const userScores = getUserScores(userEmail);

const availableSubjects = allSubjects.filter(
(subject) => !completedSubjects.includes(subject.code),
);

return {
available: availableSubjects,
completed: completedSubjects,
total: allSubjects.length,
scores: userScores.scores,
averageScore: userScores.averageScore,
totalSubjects: userScores.totalSubjects,
};
} catch (error) {
console.error("Error getting subjects:", error);
return {
available: [],
completed: [],
total: 0,
scores: [],
averageScore: 0,
};
}
}

function getCompletedSubjects(userEmail) {
try {
const resultsFile = SpreadsheetApp.openById(CONFIG.RESULTS_SHEET_ID);
const resultSheets = resultsFile.getSheets();
const completedSubjects = [];

resultSheets.forEach((sheet) => {
const data = sheet.getDataRange().getValues();

for (let i = 1; i < data.length; i++) {
const row = data[i];
const emailInSheet = row[2]; // Cột C: Email

if (
emailInSheet &&
emailInSheet.toString().trim().toLowerCase() ===
userEmail.toLowerCase()
) {
if (!completedSubjects.includes(sheet.getName())) {
completedSubjects.push(sheet.getName());
}
break;
}
}
});

return completedSubjects;
} catch (error) {
console.error("Error getting completed subjects:", error);
return [];
}
}

function getUserScores(userEmail) {
try {
if (!userEmail) return { scores: [], averageScore: 0 };

const resultsFile = SpreadsheetApp.openById(CONFIG.RESULTS_SHEET_ID);
const resultSheets = resultsFile.getSheets();
const subjectScores = {};

resultSheets.forEach((sheet) => {
const data = sheet.getDataRange().getValues();
const subjectName = sheet.getName();

let totalScore = 0;
let totalQuestions = 0;
let hasData = false;

for (let i = 1; i < data.length; i++) {
const row = data[i];
const emailInSheet = row[2]; // Cột C: Email

// ✅ FIX: Cột Diem đúng index
const scoreIndex = CONFIG.SAVE_TIME_TO_SHEET ? 10 : 9;
const score = row[scoreIndex];

if (
emailInSheet &&
emailInSheet.toString().trim().toLowerCase() ===
userEmail.toLowerCase()
) {
totalScore += score || 0;
totalQuestions++;
hasData = true;
}
}

if (hasData && totalQuestions > 0) {
subjectScores[subjectName] = {
subject: subjectName,
score: totalScore,
total: totalQuestions,
percentage: Math.round((totalScore / totalQuestions) * 100),
};
}
});

const scores = Object.values(subjectScores);
const averageScore =
scores.length > 0
? Math.round(
scores.reduce((sum, item) => sum + item.percentage, 0) /
scores.length,
)
: 0;

return {
scores: scores,
averageScore: averageScore,
totalSubjects: scores.length,
};
} catch (error) {
console.error("Error getting user scores:", error);
return { scores: [], averageScore: 0, totalSubjects: 0 };
}
}

function getRandomQuestions(subjectCode) {
try {
if (!subjectCode) {
throw new Error("Vui lòng chọn bộ đề thi");
}

const questionsFile = SpreadsheetApp.openById(CONFIG.QUESTIONS_SHEET_ID);
const questionsSheet = questionsFile.getSheetByName(subjectCode);

if (!questionsSheet) {
throw new Error(`Không tìm thấy bộ đề: ${subjectCode}`);
}

const data = questionsSheet.getDataRange().getValues();
const allQuestions = data.slice(2);
const validQuestions = allQuestions.filter(
(row) => row[0] && row[0].toString().trim(),
);

if (validQuestions.length === 0) {
throw new Error(`Không có câu hỏi nào trong bộ đề: ${subjectCode}`);
}

const shuffled = validQuestions.sort(() => 0.5 - Math.random());
const selected = shuffled.slice(
0,
Math.min(CONFIG.QUESTIONS_PER_QUIZ, validQuestions.length),
);

return selected.map((row, index) => {
const questionId = index + 1;
const questionText = row[0] ? row[0].toString().trim() : "";

const correctOptions = [];
for (let i = 1; i <= 4; i++) {
if (row[i] && row[i].toString().trim()) {
correctOptions.push({
text: row[i].toString().trim(),
isCorrect: true,
isAllCorrect: false,
isAllWrong: false,
});
}
}

if (row[5] && row[5].toString().trim()) {
correctOptions.push({
text: row[5].toString().trim(),
isCorrect: true,
isAllCorrect: true,
isAllWrong: false,
});
}

if (row[6] && row[6].toString().trim()) {
correctOptions.push({
text: row[6].toString().trim(),
isCorrect: true,
isAllCorrect: false,
isAllWrong: true,
});
}

const incorrectOptions = [];
for (let i = 7; i <= 10; i++) {
if (row[i] && row[i].toString().trim()) {
incorrectOptions.push({
text: row[i].toString().trim(),
isCorrect: false,
isAllCorrect: false,
isAllWrong: false,
});
}
}

const selectedOptions = selectOptionsWithImprovedLogic(
correctOptions,
incorrectOptions,
);

const finalOptions = {};
const correctAnswers = [];

selectedOptions.forEach((option, idx) => {
const optionLetter = String.fromCharCode(65 + idx);
finalOptions[`option${optionLetter}`] = option.text;

if (option.isCorrect) {
correctAnswers.push(optionLetter);
}
});

const displayedOptionsText = selectedOptions
.map((opt, idx) => {
const letter = String.fromCharCode(65 + idx);
return `${letter}. ${opt.text}`;
})
.join(" | ");

return {
id: questionId,
question: questionText,
...finalOptions,
subject: subjectCode,
correctAnswer: correctAnswers.join(","),
isMultiple: correctAnswers.length > 1,
totalOptions: selectedOptions.length,
displayedOptions: displayedOptionsText,
};
});
} catch (error) {
console.error("Error getting questions:", error);
throw error;
}
}

function selectOptionsWithImprovedLogic(correctOptions, incorrectOptions) {
const allCorrectOption = correctOptions.find((opt) => opt.isAllCorrect);
const allWrongOption = correctOptions.find((opt) => opt.isAllWrong);

const normalCorrectOptions = correctOptions.filter(
(opt) => !opt.isAllCorrect && !opt.isAllWrong,
);
const normalCorrectCount = normalCorrectOptions.length;

const isSingleChoice = normalCorrectCount <= 1;

if (isSingleChoice) {
if (allCorrectOption) {
const result = correctOptions.filter((opt) => !opt.isAllWrong);
return shuffleArray(result);
}

if (allWrongOption) {
const result = [...incorrectOptions, allWrongOption];
return shuffleArray(result);
}

return selectRandomOptions(normalCorrectOptions, incorrectOptions);
}

return selectRandomOptions(normalCorrectOptions, incorrectOptions);
}

function selectRandomOptions(correctOptions, incorrectOptions) {
const numOptionsToShow =
Math.floor(
Math.random() *
(CONFIG.MAX_OPTIONS_PER_QUESTION - CONFIG.MIN_OPTIONS_PER_QUESTION + 1),
) + CONFIG.MIN_OPTIONS_PER_QUESTION;

const selectedOptions = [];

if (correctOptions.length > 0) {
const randomCorrect =
correctOptions[Math.floor(Math.random() * correctOptions.length)];
selectedOptions.push(randomCorrect);
}

const remainingCorrect = correctOptions.filter(
(opt) => !selectedOptions.includes(opt),
);
const remainingPool = [...remainingCorrect, ...incorrectOptions];

while (
selectedOptions.length < numOptionsToShow &&
remainingPool.length > 0
) {
const randomIndex = Math.floor(Math.random() * remainingPool.length);
const selectedOption = remainingPool.splice(randomIndex, 1)[0];
selectedOptions.push(selectedOption);
}

return shuffleArray(selectedOptions);
}

function shuffleArray(array) {
const result = [...array];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}

/**
* ========== LƯU KẾT QUẢ VỚI THÔNG TIN THỜI GIAN ==========
*/
function saveResults(results) {
try {
if (!results.subject) {
throw new Error("Thiếu thông tin bộ đề");
}

const resultsFile = SpreadsheetApp.openById(CONFIG.RESULTS_SHEET_ID);
let resultsSheet = resultsFile.getSheetByName(results.subject);

if (!resultsSheet) {
resultsSheet = resultsFile.insertSheet(results.subject);

// === HEADER MỚI: THÊM CỘT THỜI GIAN ===
const headers = CONFIG.SAVE_TIME_TO_SHEET
? [
"ThoiGian",
"HoTen",
"Email",
"Truong",
"Khoa",
"Lop",
"KhoaHoc",
"ThoiGianSuDung",
"CauHoi",
"DapAnChon",
"Diem",
"CacDapAnXuatHien",
"DapAnDung",
]
: [
"ThoiGian",
"HoTen",
"Email",
"Truong",
"Khoa",
"Lop",
"KhoaHoc",
"CauHoi",
"DapAnChon",
"Diem",
"CacDapAnXuatHien",
"DapAnDung",
];

resultsSheet.getRange(1, 1, 1, headers.length).setValues([headers]);

const headerRange = resultsSheet.getRange(1, 1, 1, headers.length);
headerRange.setBackground("#4285f4");
headerRange.setFontColor("white");
headerRange.setFontWeight("bold");
headerRange.setFontSize(12);

resultsSheet.autoResizeColumns(1, headers.length);
}

const timestamp = new Date();

// === LƯU TỪNG CÂU TRẢ LỜI VỚI THỜI GIAN ===
results.answers.forEach((answer) => {
const rowData = [
Utilities.formatDate(
timestamp,
Session.getScriptTimeZone(),
"dd/MM/yyyy HH:mm:ss",
),
results.name,
results.email,
results.truong || "",
results.khoa || "",
results.lop || "",
results.khoaHoc || "",
];

// Thêm thời gian sử dụng nếu được bật
if (CONFIG.SAVE_TIME_TO_SHEET && results.timeUsed) {
rowData.push(results.timeUsed);
}

rowData.push(
answer.question,
answer.selectedAnswer,
answer.score,
answer.displayedOptions || "",
answer.correctAnswerText || answer.correctAnswer,
);

resultsSheet.appendRow(rowData);
});

return {
success: true,
totalScore: results.totalScore,
maxScore: results.answers.length,
subject: results.subject,
timeUsed: results.timeUsed || null,
};
} catch (error) {
console.error("Error saving results:", error);
return {
success: false,
error: error.message,
};
}
}

// ========== LEADERBOARD FUNCTIONS ==========

function getSubjectsWithResults() {
try {
const resultsFile = SpreadsheetApp.openById(CONFIG.RESULTS_SHEET_ID);
const sheets = resultsFile.getSheets();

return sheets.map((sheet) => ({
code: sheet.getName(),
name: sheet.getName(),
displayName: `Bộ đề ${sheet.getName()}`,
}));
} catch (error) {
console.error("Error getting subjects with results:", error);
return [];
}
}

function getSubjectLeaderboard(subjectCode, currentUserEmail) {
try {
const resultsFile = SpreadsheetApp.openById(CONFIG.RESULTS_SHEET_ID);
const sheet = resultsFile.getSheetByName(subjectCode);

if (!sheet) {
return { success: false, error: "Không tìm thấy bộ đề", data: [] };
}

const data = sheet.getDataRange().getValues();

if (data.length <= 1) {
return {
success: true,
data: [],
subject: subjectCode,
totalParticipants: 0,
};
}

const userResults = {};

// ✅ FIX: Đọc đúng cột Diem
const scoreIndex = CONFIG.SAVE_TIME_TO_SHEET ? 10 : 9;

for (let i = 1; i < data.length; i++) {
const row = data[i];
const timestamp = row[0];
const name = row[1];
const email = row[2];
const truong = row[3] || "";
const score = row[scoreIndex]; // ✅ Sử dụng scoreIndex động

if (!email) continue;

const emailKey = email.toString().trim().toLowerCase();

if (!userResults[emailKey]) {
userResults[emailKey] = {
name: name,
email: email,
truong: truong,
totalScore: 0,
totalQuestions: 0,
firstAttempt: timestamp,
lastAttempt: timestamp,
};
}

userResults[emailKey].totalScore += score || 0;
userResults[emailKey].totalQuestions++;

if (timestamp < userResults[emailKey].firstAttempt) {
userResults[emailKey].firstAttempt = timestamp;
}
if (timestamp > userResults[emailKey].lastAttempt) {
userResults[emailKey].lastAttempt = timestamp;
}
}

const leaderboardData = Object.values(userResults).map((user) => ({
name: user.name,
email: maskEmail(user.email),
truong: user.truong,
rawEmail: user.email.toString().trim().toLowerCase(),
score: user.totalScore,
total: user.totalQuestions,
percentage: Math.round((user.totalScore / user.totalQuestions) * 100),
completedAt: formatDateTime(user.lastAttempt),
isCurrentUser:
currentUserEmail &&
user.email.toString().trim().toLowerCase() ===
currentUserEmail.toLowerCase(),
}));

leaderboardData.sort((a, b) => {
if (b.percentage !== a.percentage) {
return b.percentage - a.percentage;
}
return new Date(a.completedAt) - new Date(b.completedAt);
});

leaderboardData.forEach((item, index) => {
item.rank = index + 1;
});

return {
success: true,
subject: subjectCode,
data: leaderboardData,
totalParticipants: leaderboardData.length,
};
} catch (error) {
console.error("Error getting subject leaderboard:", error);
return { success: false, error: error.message, data: [] };
}
}

function getOverallLeaderboard(currentUserEmail) {
try {
const resultsFile = SpreadsheetApp.openById(CONFIG.RESULTS_SHEET_ID);
const sheets = resultsFile.getSheets();

const userResults = {};

// ✅ FIX: Đọc đúng cột Diem
const scoreIndex = CONFIG.SAVE_TIME_TO_SHEET ? 10 : 9;

sheets.forEach((sheet) => {
const subjectName = sheet.getName();
const data = sheet.getDataRange().getValues();

for (let i = 1; i < data.length; i++) {
const row = data[i];
const timestamp = row[0];
const name = row[1];
const email = row[2];
const truong = row[3] || "";
const score = row[scoreIndex]; // ✅ Sử dụng scoreIndex động

if (!email) continue;

const emailKey = email.toString().trim().toLowerCase();

if (!userResults[emailKey]) {
userResults[emailKey] = {
name: name,
email: email,
truong: truong,
subjects: {},
totalScore: 0,
totalQuestions: 0,
subjectsCompleted: 0,
firstAttempt: timestamp,
lastAttempt: timestamp,
};
}

if (!userResults[emailKey].subjects[subjectName]) {
userResults[emailKey].subjects[subjectName] = {
score: 0,
total: 0,
};
userResults[emailKey].subjectsCompleted++;
}

userResults[emailKey].subjects[subjectName].score += score || 0;
userResults[emailKey].subjects[subjectName].total++;
userResults[emailKey].totalScore += score || 0;
userResults[emailKey].totalQuestions++;

if (timestamp < userResults[emailKey].firstAttempt) {
userResults[emailKey].firstAttempt = timestamp;
}
if (timestamp > userResults[emailKey].lastAttempt) {
userResults[emailKey].lastAttempt = timestamp;
}
}
});

const leaderboardData = Object.values(userResults).map((user) => {
const subjectPercentages = Object.values(user.subjects).map((s) =>
Math.round((s.score / s.total) * 100),
);
const averagePercentage =
subjectPercentages.length > 0
? Math.round(
subjectPercentages.reduce((a, b) => a + b, 0) /
subjectPercentages.length,
)
: 0;

return {
name: user.name,
email: maskEmail(user.email),
truong: user.truong,
rawEmail: user.email.toString().trim().toLowerCase(),
score: user.totalScore,
total: user.totalQuestions,
percentage: averagePercentage,
subjectsCompleted: user.subjectsCompleted,
completedAt: formatDateTime(user.lastAttempt),
isCurrentUser:
currentUserEmail &&
user.email.toString().trim().toLowerCase() ===
currentUserEmail.toLowerCase(),
};
});

leaderboardData.sort((a, b) => {
if (b.percentage !== a.percentage) {
return b.percentage - a.percentage;
}
if (b.subjectsCompleted !== a.subjectsCompleted) {
return b.subjectsCompleted - a.subjectsCompleted;
}
return new Date(a.completedAt) - new Date(b.completedAt);
});

leaderboardData.forEach((item, index) => {
item.rank = index + 1;
});

return {
success: true,
data: leaderboardData,
totalParticipants: leaderboardData.length,
totalSubjects: sheets.length,
};
} catch (error) {
console.error("Error getting overall leaderboard:", error);
return { success: false, error: error.message, data: [] };
}
}

function maskEmail(email) {
if (!email) return "";

const emailStr = email.toString().trim();
const atIndex = emailStr.indexOf("@");

if (atIndex <= 0) return emailStr;

const localPart = emailStr.substring(0, atIndex);
const domainPart = emailStr.substring(atIndex);

if (localPart.length <= 3) {
return localPart.charAt(0) + "***" + domainPart;
}

const visiblePart = localPart.substring(0, 3);
return visiblePart + "***" + domainPart;
}

function formatDateTime(dateValue) {
if (!dateValue) return "";

try {
const date = new Date(dateValue);
return Utilities.formatDate(
date,
Session.getScriptTimeZone(),
"dd/MM/yyyy HH:mm",
);
} catch (e) {
return dateValue.toString();
}
}

// ========== AUTO-FILL USER INFO ==========

/**
* Lấy thông tin user từ email (lần nộp bài gần nhất)
*/
function getUserInfoByEmail(email) {
try {
if (!email) return null;

const resultsFile = SpreadsheetApp.openById(CONFIG.RESULTS_SHEET_ID);
const sheets = resultsFile.getSheets();

let latestRecord = null;
let latestTimestamp = null;

sheets.forEach((sheet) => {
const data = sheet.getDataRange().getValues();

for (let i = 1; i < data.length; i++) {
const row = data[i];
const timestamp = row[0];
const emailInSheet = row[2]; // Cột C: Email

if (
emailInSheet &&
emailInSheet.toString().trim().toLowerCase() === email.toLowerCase()
) {
if (!latestTimestamp || timestamp > latestTimestamp) {
latestTimestamp = timestamp;
latestRecord = {
name: row[1],
email: row[2],
truong: row[3] || "",
khoa: row[4] || "",
lop: row[5] || "",
khoaHoc: row[6] || "",
};
}
}
}
});

return latestRecord;
} catch (error) {
console.error("Error getting user info:", error);
return null;
}
}

// ========== END OF CODE.GS ==========

Thay thế giá trị biến QUESTIONS_SHEET_ID bằng id sheet ngân hàng câu hỏi

Thay thế giá trị biến RESULTS_SHEET_ID bằng id sheet câu trả lời

Thay đổi giá số lượng câu hỏi bài kiểm tra bằng cách thay đổi giá trị biến QUESTIONS_PER_QUIZ

Thay đổi giá số lượng câu trả lời tối thiểu mỗi câu hỏi bằng cách thay đổi giá trị biến MIN_OPTIONS_PER_QUESTION

Thay đổi giá số lượng câu trả lời tối đa mỗi câu hỏi bằng cách thay đổi giá trị biến MAX_OPTIONS_PER_QUESTION

Thay đổi thời gian làm bài bằng cách thay đổi giá trị biến QUIZ_TIME_MINUTES

Tạo file code và tùy chỉnh

2/ Tạo file html giao diện app là index.html sau đó dán đoạn mã sau vào file => bấm Save

📄 Google Apps Script (Click để xem/ẩn)
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Quiz Online - Theo Dõi Tiến Độ</title>
<style>
/* BASE STYLES */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

body {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}

.container {
max-width: 900px;
margin: 0 auto;
background: white;
border-radius: 15px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
overflow: hidden;
animation: slideIn 0.5s ease-out;
}

@keyframes slideIn {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

.content {
padding: 30px;
}

/* HEADER */
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}

.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
}

.header p {
font-size: 1.1em;
opacity: 0.9;
}

.header-actions {
margin-top: 15px;
}

.leaderboard-btn {
background: rgba(255, 255, 255, 0.2);
color: white;
border: 2px solid rgba(255, 255, 255, 0.5);
padding: 10px 25px;
font-size: 16px;
font-weight: 600;
border-radius: 25px;
cursor: pointer;
transition: all 0.3s;
}

.leaderboard-btn:hover {
background: rgba(255, 255, 255, 0.3);
border-color: white;
transform: translateY(-2px);
}

/* COUNTDOWN TIMER - CẢI TIẾN */
.countdown-container {
position: sticky;
top: 20px;
z-index: 1000;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 15px;
padding: 20px;
margin-bottom: 25px;
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.3);
text-align: center;
transition: all 0.3s ease;
}

.countdown-header {
color: white;
font-size: 1em;
margin-bottom: 10px;
font-weight: 600;
opacity: 0.9;
}

.countdown-icon {
font-size: 1.5em;
margin-right: 10px;
}

.countdown-timer {
display: flex;
justify-content: center;
gap: 15px;
align-items: center;
}

.time-block {
background: rgba(255, 255, 255, 0.2);
border-radius: 10px;
padding: 15px 20px;
min-width: 80px;
backdrop-filter: blur(10px);
}

.time-value {
font-size: 2.5em;
font-weight: bold;
color: white;
line-height: 1;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
}

.time-label {
font-size: 0.8em;
color: rgba(255, 255, 255, 0.8);
margin-top: 5px;
text-transform: uppercase;
letter-spacing: 1px;
}

.time-separator {
font-size: 2em;
color: white;
font-weight: bold;
opacity: 0.7;
}

/* Màu cảnh báo theo config */
.countdown-container.warning-yellow {
background: linear-gradient(135deg, #f39c12 0%, #f1c40f 100%);
animation: pulse-yellow 2s infinite;
}

.countdown-container.warning-orange {
background: linear-gradient(135deg, #e67e22 0%, #d35400 100%);
animation: pulse-orange 1.5s infinite;
}

.countdown-container.warning-red {
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
animation: shake 0.5s infinite;
}

@keyframes pulse-yellow {
0%,
100% {
transform: scale(1);
box-shadow: 0 10px 30px rgba(243, 156, 18, 0.3);
}
50% {
transform: scale(1.02);
box-shadow: 0 15px 40px rgba(243, 156, 18, 0.5);
}
}

@keyframes pulse-orange {
0%,
100% {
transform: scale(1);
box-shadow: 0 10px 30px rgba(230, 126, 34, 0.4);
}
50% {
transform: scale(1.03);
box-shadow: 0 15px 40px rgba(230, 126, 34, 0.6);
}
}

@keyframes shake {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-5px);
}
75% {
transform: translateX(5px);
}
}

.countdown-warning {
margin-top: 10px;
padding: 8px 15px;
background: rgba(255, 255, 255, 0.9);
border-radius: 20px;
font-size: 0.9em;
font-weight: 600;
color: #e74c3c;
display: none;
}

.countdown-container.warning-red .countdown-warning {
display: block;
animation: blink 1s infinite;
}

@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}

@keyframes slideInRight {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}

@keyframes slideOutRight {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(400px);
opacity: 0;
}
}

/* FORM SECTIONS */
.form-section {
background: #f8f9ff;
border: 2px solid #e1e8ff;
border-radius: 12px;
padding: 25px;
margin-bottom: 25px;
transition: all 0.3s;
}

.form-section:hover {
border-color: #667eea;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.1);
}

.form-section-header {
display: flex;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #e1e8ff;
}

.form-section-icon {
font-size: 2em;
margin-right: 15px;
}

.form-section-title {
color: #333;
font-size: 1.3em;
font-weight: 600;
}

.form-group {
margin-bottom: 25px;
}

label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #333;
font-size: 0.95em;
}

label .required {
color: #dc3545;
margin-left: 3px;
}

input[type="text"],
input[type="email"] {
width: 100%;
padding: 12px;
border: 2px solid #e1e8ff;
border-radius: 8px;
font-size: 16px;
transition: all 0.3s;
}

input[type="text"]:focus,
input[type="email"]:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}

.form-hint {
color: #666;
font-size: 0.85em;
margin-top: 6px;
font-style: italic;
}

/* SUBJECT SELECTOR */
.subject-selector {
background: #f8f9ff;
border: 1px solid #e1e8ff;
border-radius: 10px;
padding: 20px;
margin-bottom: 25px;
}

.subject-selector h3 {
color: #333;
margin-bottom: 15px;
font-size: 1.3em;
}

.subject-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
margin-top: 15px;
}

.subject-card {
background: white;
border: 2px solid #e1e8ff;
border-radius: 12px;
padding: 20px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
position: relative;
overflow: hidden;
}

.subject-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
transform: scaleX(0);
transition: transform 0.3s;
}

.subject-card:hover {
border-color: #667eea;
transform: translateY(-5px);
box-shadow: 0 10px 25px rgba(102, 126, 234, 0.2);
}

.subject-card:hover::before {
transform: scaleX(1);
}

.subject-card.selected {
border-color: #667eea;
background: linear-gradient(135deg, #f0f4ff 0%, #f8f9ff 100%);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.3);
}

.subject-card.selected::before {
transform: scaleX(1);
}

.subject-card .icon {
font-size: 2em;
margin-bottom: 10px;
display: block;
}

.subject-card h4 {
color: #333;
margin-bottom: 5px;
font-size: 1.1em;
}

.subject-card p {
color: #666;
font-size: 0.9em;
}

/* QUIZ QUESTIONS */
.current-subject {
background: linear-gradient(135deg, #e8f4f8 0%, #f0f8ff 100%);
border: 1px solid #b3d9e8;
border-radius: 10px;
padding: 20px;
margin-bottom: 25px;
text-align: center;
}

.current-subject h3 {
color: #2c5aa0;
margin-bottom: 5px;
font-size: 1.3em;
}

.current-subject p {
color: #5a7ba0;
}

.question {
background: #f8f9ff;
border: 1px solid #e1e8ff;
border-radius: 12px;
padding: 25px;
margin-bottom: 25px;
transition: all 0.3s;
}

.question:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}

.question h3 {
color: #333;
margin-bottom: 20px;
font-size: 1.2em;
line-height: 1.5;
}

.question.unanswered {
border-left: 4px solid #dc3545;
background: #fff5f5;
}

.question.answered {
border-left: 4px solid #28a745;
}

.options-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 10px;
margin-top: 15px;
}

.option {
margin-bottom: 12px;
display: flex;
align-items: center;
padding: 12px;
border-radius: 8px;
transition: all 0.2s;
cursor: pointer;
border: 1px solid #e1e8ff;
background: white;
}

.option:hover {
background-color: #f0f4ff;
border-color: #667eea;
}

.option.selected {
background-color: #f0f4ff;
border-color: #667eea;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.2);
}

.option input {
margin-right: 12px;
transform: scale(1.3);
}

.option label {
margin-bottom: 0;
cursor: pointer;
font-weight: normal;
flex: 1;
line-height: 1.4;
}

/* BUTTONS */
.btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 15px 40px;
font-size: 18px;
font-weight: 600;
border-radius: 25px;
cursor: pointer;
transition: all 0.3s;
display: block;
margin: 30px auto;
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}

.btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.6);
}

.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}

.btn-secondary {
background: #f0f4ff;
color: #667eea;
border: 2px solid #667eea;
box-shadow: none;
}

.btn-secondary:hover {
background: #667eea;
color: white;
}

/* RESULTS */
.result {
text-align: center;
padding: 40px;
}

.score {
font-size: 4em;
font-weight: bold;
margin: 20px 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}

.score-detail {
font-size: 1.2em;
color: #666;
margin-bottom: 20px;
}

.score-summary {
background: linear-gradient(135deg, #f0f8ff 0%, #e8f4f8 100%);
border: 1px solid #b3d9e8;
border-radius: 12px;
padding: 20px;
margin-bottom: 25px;
text-align: center;
}

.score-summary h4 {
color: #2c5aa0;
margin-bottom: 15px;
font-size: 1.2em;
}

.average-score {
font-size: 2.5em;
font-weight: bold;
color: #667eea;
margin: 10px 0;
}

.scores-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
}

.score-card {
background: white;
border: 1px solid #e1e8ff;
border-radius: 8px;
padding: 15px;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.score-card h5 {
color: #333;
margin-bottom: 10px;
font-size: 1em;
}

.score-value {
font-size: 1.8em;
font-weight: bold;
margin: 5px 0;
}

.score-percentage {
color: #666;
font-size: 0.9em;
}

.result-actions {
display: flex;
gap: 15px;
justify-content: center;
flex-wrap: wrap;
margin-top: 20px;
}

/* LEADERBOARD */
.leaderboard-section {
padding: 20px;
}

.leaderboard-header {
text-align: center;
margin-bottom: 25px;
}

.leaderboard-header h2 {
color: #333;
font-size: 1.8em;
margin-bottom: 10px;
}

.leaderboard-header p {
color: #666;
}

.leaderboard-tabs {
display: flex;
justify-content: center;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}

.tab-btn {
padding: 12px 24px;
border: 2px solid #e1e8ff;
background: white;
border-radius: 25px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
color: #666;
transition: all 0.3s;
}

.tab-btn:hover {
border-color: #667eea;
color: #667eea;
}

.tab-btn.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-color: transparent;
}

.subject-filter {
margin-bottom: 20px;
text-align: center;
}

.subject-filter select {
padding: 10px 20px;
border: 2px solid #e1e8ff;
border-radius: 8px;
font-size: 16px;
min-width: 200px;
cursor: pointer;
}

.subject-filter select:focus {
outline: none;
border-color: #667eea;
}

.leaderboard-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
margin-bottom: 25px;
}

.stat-card {
background: white;
border: 1px solid #e1e8ff;
border-radius: 12px;
padding: 20px;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}

.stat-card .stat-icon {
font-size: 2em;
margin-bottom: 10px;
}

.stat-card .stat-value {
font-size: 1.8em;
font-weight: bold;
color: #667eea;
}

.stat-card .stat-label {
color: #666;
font-size: 0.9em;
margin-top: 5px;
}

.leaderboard-table-container {
overflow-x: auto;
border-radius: 12px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
}

.leaderboard-table {
width: 100%;
border-collapse: collapse;
background: white;
}

.leaderboard-table th {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 15px 12px;
text-align: left;
font-weight: 600;
white-space: nowrap;
}

.leaderboard-table th:first-child {
border-radius: 12px 0 0 0;
}

.leaderboard-table th:last-child {
border-radius: 0 12px 0 0;
}

.leaderboard-table td {
padding: 12px;
border-bottom: 1px solid #e1e8ff;
}

.leaderboard-table tr:hover {
background: #f8f9ff;
}

.leaderboard-table tr:last-child td:first-child {
border-radius: 0 0 0 12px;
}

.leaderboard-table tr:last-child td:last-child {
border-radius: 0 0 12px 0;
}

.leaderboard-table tr.current-user {
background: linear-gradient(135deg, #fff9e6 0%, #fff3cd 100%);
font-weight: 600;
}

.leaderboard-table tr.current-user:hover {
background: linear-gradient(135deg, #fff3cd 0%, #ffeaa7 100%);
}

.rank-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
font-weight: bold;
font-size: 14px;
}

.rank-1 {
background: linear-gradient(135deg, #ffd700 0%, #ffb300 100%);
color: #333;
box-shadow: 0 2px 8px rgba(255, 215, 0, 0.5);
}

.rank-2 {
background: linear-gradient(135deg, #c0c0c0 0%, #a8a8a8 100%);
color: #333;
box-shadow: 0 2px 8px rgba(192, 192, 192, 0.5);
}

.rank-3 {
background: linear-gradient(135deg, #cd7f32 0%, #b5651d 100%);
color: white;
box-shadow: 0 2px 8px rgba(205, 127, 50, 0.5);
}

.rank-other {
background: #f0f4ff;
color: #667eea;
}

.percentage-bar {
display: flex;
align-items: center;
gap: 10px;
}

.percentage-bar-bg {
flex: 1;
height: 8px;
background: #e1e8ff;
border-radius: 4px;
overflow: hidden;
min-width: 60px;
}

.percentage-bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.5s ease;
}

.percentage-excellent {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
}

.percentage-good {
background: linear-gradient(135deg, #17a2b8 0%, #6f42c1 100%);
}

.percentage-average {
background: linear-gradient(135deg, #ffc107 0%, #fd7e14 100%);
}

.percentage-poor {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
}

.percentage-text {
font-weight: 600;
min-width: 45px;
text-align: right;
}

/* UTILITY STYLES */
.loading {
text-align: center;
padding: 40px;
}

.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
width: 50px;
height: 50px;
animation: spin 1s linear infinite;
margin: 20px auto;
}

@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

.error {
background: #ffe6e6;
border: 1px solid #ffcccc;
color: #cc0000;
padding: 20px;
border-radius: 8px;
margin: 15px 0;
text-align: center;
}

.warning-message {
background: #fff3cd;
border: 1px solid #ffeaa7;
color: #856404;
padding: 15px;
border-radius: 8px;
margin: 15px 0;
text-align: center;
display: none;
}

.info-box {
background: #e8f5e8;
border: 1px solid #c3e6c3;
color: #2d5a2d;
padding: 15px;
border-radius: 8px;
margin: 15px 0;
}

.no-data {
text-align: center;
padding: 40px;
color: #666;
}

.no-data .icon {
font-size: 4em;
margin-bottom: 15px;
opacity: 0.5;
}

.back-btn-container {
text-align: center;
margin-top: 25px;
}

/* RESPONSIVE DESIGN */
@media (max-width: 600px) {
.container {
margin: 10px;
border-radius: 10px;
}

.content {
padding: 20px;
}

.header h1 {
font-size: 2em;
}

.score {
font-size: 3em;
}

.subject-grid {
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
}

.subject-card {
padding: 15px;
}

.options-grid {
grid-template-columns: 1fr;
}

.option {
margin-bottom: 8px;
padding: 10px;
}

.countdown-container {
position: relative;
top: 0;
padding: 15px;
margin-bottom: 20px;
}

.countdown-timer {
gap: 10px;
}

.time-block {
padding: 10px 15px;
min-width: 60px;
}

.time-value {
font-size: 2em;
}

.time-label {
font-size: 0.7em;
}

.time-separator {
font-size: 1.5em;
}

.countdown-header {
font-size: 0.9em;
}

.countdown-icon {
font-size: 1.2em;
}

.form-section {
padding: 20px;
}

.form-section-header {
margin-bottom: 15px;
}

.form-section-icon {
font-size: 1.5em;
margin-right: 10px;
}

.form-section-title {
font-size: 1.1em;
}
}
</style>
</head>

<body>
<div class="container">
<div class="header">
<h1>🎯 Quiz Online</h1>
<p>Hệ thống theo dõi tiến độ làm bài</p>
<div class="header-actions">
<button class="leaderboard-btn" onclick="showLeaderboard()">
🏆 Bảng Xếp Hạng
</button>
</div>
</div>

<div class="content">
<!-- Form nhập thông tin -->
<div id="subjectSelection">
<div
class="countdown-container"
id="countdownContainerSelection"
style="display: none;"
>
<div class="countdown-header">
<span class="countdown-icon">⏱️</span>
<span>Thời gian làm bài còn lại</span>
</div>
<div class="countdown-timer">
<div class="time-block">
<div class="time-value" id="minutesDisplaySelection">--</div>
<div class="time-label">Phút</div>
</div>
<div class="time-separator">:</div>
<div class="time-block">
<div class="time-value" id="secondsDisplaySelection">--</div>
<div class="time-label">Giây</div>
</div>
</div>
<div class="countdown-warning">
⚠️ Sắp hết giờ! Vui lòng nộp bài ngay!
</div>
</div>

<div class="form-section">
<div class="form-section-header">
<div class="form-section-icon">👤</div>
<div class="form-section-title">Thông tin cá nhân</div>
</div>

<div class="form-group">
<label for="studentEmail"
>Email <span class="required">*</span></label
>
<input
type="email"
id="studentEmail"
placeholder="example@email.com"
required
onblur="checkEmailAndLoadTimer()"
/>
<div class="form-hint">
💡 Email được dùng để tra cứu kết quả và tự động điền thông tin
</div>
<div
id="autoFillStatus"
style="color: #667eea; font-size: 0.9em; margin-top: 8px; font-weight: 600;"
></div>
</div>

<div class="form-group">
<label for="studentName"
>Họ và tên <span class="required">*</span></label
>
<input
type="text"
id="studentName"
placeholder="Ví dụ: Nguyễn Văn A"
required
/>
</div>
</div>

<div class="form-section">
<div class="form-section-header">
<div class="form-section-icon">🏫</div>
<div class="form-section-title">Thông tin trường học</div>
</div>

<div class="form-group">
<label for="studentTruong"
>Tên trường <span class="required">*</span></label
>
<input
type="text"
id="studentTruong"
placeholder="Ví dụ: Đại học Cần Thơ"
required
/>
</div>

<div class="form-group">
<label for="studentKhoa"
>Khoa <span class="required">*</span></label
>
<input
type="text"
id="studentKhoa"
placeholder="Ví dụ: Khoa Công nghệ Thông tin"
required
/>
</div>

<div class="form-group">
<label for="studentLop"
>Lớp <span class="required">*</span></label
>
<input
type="text"
id="studentLop"
placeholder="Ví dụ: DI19V7F1"
required
/>
</div>

<div class="form-group">
<label for="studentKhoaHoc"
>Khóa học <span class="required">*</span></label
>
<input
type="text"
id="studentKhoaHoc"
placeholder="Ví dụ: K47"
required
/>
</div>
</div>

<button
class="btn"
onclick="submitInfoAndStartTimer()"
id="checkEmailBtn"
>
🚀 Xác nhận thông tin & Bắt đầu làm bài
</button>

<div
class="subject-selector"
id="subjectSelectorContainer"
style="display: none;"
>
<h3>📚 Chọn bộ đề thi:</h3>
<div id="subjectGrid" class="subject-grid"></div>
</div>

<button
class="btn"
onclick="startQuiz()"
id="startBtn"
disabled
style="display: none;"
>
🚀 Bắt đầu làm bài
</button>
</div>

<!-- Loading -->
<div id="loading" class="loading" style="display: none;">
<div class="spinner"></div>
<p>Đang tải câu hỏi ngẫu nhiên...</p>
</div>

<!-- Quiz -->
<div id="quiz" style="display: none;">
<div class="countdown-container" id="countdownContainer">
<div class="countdown-header">
<span class="countdown-icon">⏱️</span>
<span>Thời gian còn lại</span>
</div>
<div class="countdown-timer">
<div class="time-block">
<div class="time-value" id="minutesDisplay">--</div>
<div class="time-label">Phút</div>
</div>
<div class="time-separator">:</div>
<div class="time-block">
<div class="time-value" id="secondsDisplay">--</div>
<div class="time-label">Giây</div>
</div>
</div>
<div class="countdown-warning">
⚠️ Sắp hết giờ! Vui lòng nộp bài ngay!
</div>
</div>

<div class="current-subject">
<h3 id="currentSubjectName">📋 Bộ đề:</h3>
<p>Danh sách câu hỏi ngẫu nhiên từ ngân hàng câu hỏi</p>
</div>

<form id="quizForm">
<div id="questions"></div>
<div id="warningMessage" class="warning-message">
⚠️ Vui lòng trả lời tất cả các câu hỏi trước khi nộp bài!
</div>
<button type="submit" class="btn" id="submitBtn" disabled>
✅ Nộp bài
</button>
</form>
</div>

<!-- Kết quả -->
<div id="result" class="result" style="display: none;">
<h2>🎉 Hoàn thành bài kiểm tra!</h2>
<div class="score" id="finalScore"></div>
<div class="score-detail" id="scoreDetail"></div>
<div class="info-box" id="resultInfo"></div>
<p>Cảm ơn bạn đã tham gia! Kết quả đã được lưu lại.</p>
<div class="result-actions">
<button class="btn" onclick="showLeaderboard()">
🏆 Xem Bảng Xếp Hạng
</button>
<button class="btn btn-secondary" onclick="returnToSelection()">
🔄 Làm bộ đề khác
</button>
</div>
</div>

<!-- Leaderboard -->
<div
id="leaderboard"
class="leaderboard-section"
style="display: none;"
>
<div class="leaderboard-header">
<h2>🏆 Bảng Xếp Hạng</h2>
<p>Xem thành tích của tất cả thí sinh</p>
</div>

<div class="leaderboard-tabs">
<button
class="tab-btn active"
onclick="switchLeaderboardTab('overall')"
id="tabOverall"
>
📊 Tổng hợp
</button>
<button
class="tab-btn"
onclick="switchLeaderboardTab('subject')"
id="tabSubject"
>
📚 Theo bộ đề
</button>
</div>

<div
class="subject-filter"
id="subjectFilterContainer"
style="display: none;"
>
<select id="subjectFilter" onchange="loadSubjectLeaderboard()">
<option value="">-- Chọn bộ đề --</option>
</select>
</div>

<div class="leaderboard-stats" id="leaderboardStats"></div>

<div class="leaderboard-table-container">
<div id="leaderboardContent">
<div class="loading">
<div class="spinner"></div>
<p>Đang tải bảng xếp hạng...</p>
</div>
</div>
</div>

<div class="back-btn-container">
<button class="btn btn-secondary" onclick="hideLeaderboard()">
← Quay lại
</button>
</div>
</div>

<!-- Error -->
<div id="error" class="error" style="display: none;">
<h3>⚠️ Có lỗi xảy ra</h3>
<p id="errorMessage"></p>
<button class="btn" onclick="location.reload()">🔄 Thử lại</button>
</div>
</div>
</div>

<script>
// ========== CẤU HÌNH TIMER (TỪ SERVER) ==========
let TIMER_CONFIG = {
quizTimeMinutes: 50,
quizTimeSeconds: 3000,
warningTimes: { YELLOW: 10, ORANGE: 5, RED: 1 },
autoSubmit: true,
showTimeUsed: true,
timerUpdateInterval: 1000,
};

// Load config từ server
google.script.run
.withSuccessHandler(function (config) {
TIMER_CONFIG = config;
console.log("✅ Đã load config:", TIMER_CONFIG);
})
.withFailureHandler(function (error) {
console.error("❌ Lỗi load config, dùng mặc định:", error);
})
.getQuizConfig();

// ========== BIẾN TOÀN CỤC ==========
let questions = [];
let studentInfo = {};
let selectedSubject = "";
let lastCompletedSubject = "";
let currentLeaderboardTab = "overall";

// Timer variables
let countdownInterval = null;
let totalTimeInSeconds = 0;
let remainingTime = 0;
let startTime = null;
let countdownStarted = false;

// ========== LOCALSTORAGE TIMER MANAGEMENT ==========

const STORAGE_KEYS = {
TIMER_START: "quiz_timer_start",
TIMER_REMAINING: "quiz_timer_remaining",
USER_EMAIL: "quiz_user_email",
};

function saveTimerToStorage() {
if (!emailConfirmed) return;

const email = document.getElementById("studentEmail").value.trim();
if (!email) return;

try {
localStorage.setItem(STORAGE_KEYS.USER_EMAIL, email);
localStorage.setItem(STORAGE_KEYS.TIMER_START, startTime);
localStorage.setItem(STORAGE_KEYS.TIMER_REMAINING, remainingTime);
console.log("💾 Đã lưu timer:", { email, remainingTime });
} catch (e) {
console.error("❌ Lỗi lưu timer:", e);
}
}

function loadTimerFromStorage(email) {
if (!email) return null;

try {
const savedEmail = localStorage.getItem(STORAGE_KEYS.USER_EMAIL);

// Chỉ load nếu cùng email
if (savedEmail && savedEmail.toLowerCase() === email.toLowerCase()) {
const savedRemaining = parseInt(
localStorage.getItem(STORAGE_KEYS.TIMER_REMAINING),
);
const savedStart = parseInt(
localStorage.getItem(STORAGE_KEYS.TIMER_START),
);

if (savedRemaining > 0) {
console.log("📂 Đã load timer:", { email, savedRemaining });
return { remainingTime: savedRemaining, startTime: savedStart };
}
}
} catch (e) {
console.error("❌ Lỗi load timer:", e);
}

return null;
}

function clearTimerFromStorage() {
try {
localStorage.removeItem(STORAGE_KEYS.TIMER_START);
localStorage.removeItem(STORAGE_KEYS.TIMER_REMAINING);
localStorage.removeItem(STORAGE_KEYS.USER_EMAIL);
console.log("🗑️ Đã xóa timer storage");
} catch (e) {
console.error("❌ Lỗi xóa timer:", e);
}
}

function checkEmailAndLoadTimer() {
const email = document.getElementById("studentEmail").value.trim();

if (!email) {
document.getElementById("autoFillStatus").textContent = "";
return;
}

const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
document.getElementById("autoFillStatus").textContent =
"⚠️ Email không hợp lệ";
return;
}

document.getElementById("autoFillStatus").textContent =
"🔍 Đang tìm thông tin...";

// Load thông tin user từ Sheet
google.script.run
.withSuccessHandler(function (userInfo) {
if (userInfo) {
// Auto-fill form
document.getElementById("studentName").value =
userInfo.name || "";
document.getElementById("studentTruong").value =
userInfo.truong || "";
document.getElementById("studentKhoa").value =
userInfo.khoa || "";
document.getElementById("studentLop").value = userInfo.lop || "";
document.getElementById("studentKhoaHoc").value =
userInfo.khoaHoc || "";

document.getElementById("autoFillStatus").textContent =
"✅ Đã tải thông tin từ lần thi trước";
setTimeout(() => {
document.getElementById("autoFillStatus").textContent = "";
}, 3000);
} else {
document.getElementById("autoFillStatus").textContent =
"📝 Email mới - Vui lòng điền thông tin";
setTimeout(() => {
document.getElementById("autoFillStatus").textContent = "";
}, 3000);
}
})
.withFailureHandler(function (error) {
console.error("Lỗi load user info:", error);
document.getElementById("autoFillStatus").textContent = "";
})
.getUserInfoByEmail(email);
}

let emailConfirmed = false;

// ========== TIMER FUNCTIONS (CẢI TIẾN) ==========

function startCountdown() {
if (countdownInterval) {
console.log("⏱️ Countdown đã đang chạy");
return;
}

totalTimeInSeconds = TIMER_CONFIG.quizTimeSeconds;
remainingTime = totalTimeInSeconds;
startTime = Date.now();

console.log(
"⏱️ Bắt đầu countdown:",
TIMER_CONFIG.quizTimeMinutes,
"phút",
);

updateCountdownDisplay();

countdownInterval = setInterval(() => {
remainingTime--;
updateCountdownDisplay();
updateCountdownStyle();
checkWarningLevels();

// 💾 Lưu timer mỗi 5 giây
if (remainingTime % 5 === 0) {
saveTimerToStorage();
}

if (remainingTime <= 0) {
clearInterval(countdownInterval);
countdownInterval = null;
clearTimerFromStorage(); // Xóa storage khi hết giờ
handleTimeUp();
}
}, TIMER_CONFIG.timerUpdateInterval);
}

function updateCountdownDisplay() {
const minutes = Math.floor(remainingTime / 60);
const seconds = remainingTime % 60;

const minutesStr = minutes.toString().padStart(2, "0");
const secondsStr = seconds.toString().padStart(2, "0");

["Selection", ""].forEach((suffix) => {
const minutesEl = document.getElementById(`minutesDisplay${suffix}`);
const secondsEl = document.getElementById(`secondsDisplay${suffix}`);

if (minutesEl) minutesEl.textContent = minutesStr;
if (secondsEl) secondsEl.textContent = secondsStr;
});
}

function updateCountdownStyle() {
const minutesRemaining = Math.floor(remainingTime / 60);

["countdownContainerSelection", "countdownContainer"].forEach((id) => {
const container = document.getElementById(id);
if (!container) return;

container.classList.remove(
"warning-yellow",
"warning-orange",
"warning-red",
);

if (minutesRemaining <= TIMER_CONFIG.warningTimes.RED) {
container.classList.add("warning-red");
} else if (minutesRemaining <= TIMER_CONFIG.warningTimes.ORANGE) {
container.classList.add("warning-orange");
} else if (minutesRemaining <= TIMER_CONFIG.warningTimes.YELLOW) {
container.classList.add("warning-yellow");
}
});
}

function checkWarningLevels() {
const minutesRemaining = Math.floor(remainingTime / 60);
const secondsInMinute = remainingTime % 60;

if (secondsInMinute === 59) {
const warnings = TIMER_CONFIG.warningTimes;

if (minutesRemaining === warnings.YELLOW) {
showTimeWarning(`⚠️ Còn ${warnings.YELLOW} phút!`, "warning");
} else if (minutesRemaining === warnings.ORANGE) {
showTimeWarning(`⏰ Còn ${warnings.ORANGE} phút!`, "warning");
} else if (minutesRemaining === warnings.RED) {
showTimeWarning(`🚨 CHỈ CÒN ${warnings.RED} PHÚT!`, "danger");
}
}
}

function showTimeWarning(message, type = "warning") {
const notification = document.createElement("div");
notification.style.cssText = `
position: fixed;
top: 100px;
right: 20px;
background: ${type === "danger" ? "#e74c3c" : "#f39c12"};
color: white;
padding: 15px 25px;
border-radius: 10px;
box-shadow: 0 5px 20px rgba(0,0,0,0.3);
font-size: 1.1em;
font-weight: bold;
z-index: 10000;
animation: slideInRight 0.5s ease-out;
`;
notification.textContent = message;

document.body.appendChild(notification);

setTimeout(() => {
notification.style.animation = "slideOutRight 0.5s ease-in";
setTimeout(() => notification.remove(), 500);
}, 3000);
}

function getTimeUsed() {
if (!startTime) return null;

const timeUsedSeconds = totalTimeInSeconds - remainingTime;
const minutes = Math.floor(timeUsedSeconds / 60);
const seconds = timeUsedSeconds % 60;

return {
seconds: timeUsedSeconds,
formatted: `${minutes} phút ${seconds} giây`,
minutes: minutes,
secondsOnly: seconds,
};
}

function handleTimeUp() {
console.log("⏰ HẾT GIỜ!");

clearTimerFromStorage();

// Dừng timer vĩnh viễn
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}

const quizVisible =
document.getElementById("quiz").style.display !== "none";

if (quizVisible && TIMER_CONFIG.autoSubmit) {
// Đang làm bài - tự động nộp
alert("⏰ HẾT GIỜ! Hệ thống sẽ tự động nộp bài của bạn.");
submitQuiz(true);
} else {
// Đang chọn bộ đề hoặc xem kết quả
alert("⏰ HẾT GIỜ! Bạn đã hết thời gian làm tất cả bộ đề.");

// Vô hiệu hóa tất cả các nút
const startBtn = document.getElementById("startBtn");
if (startBtn) {
startBtn.disabled = true;
startBtn.innerHTML = "⏰ Hết giờ làm bài";
}

const submitBtn = document.getElementById("submitBtn");
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerHTML = "⏰ Hết giờ - Không thể nộp bài";
}

// Vô hiệu hóa chọn bộ đề
document.querySelectorAll(".subject-card").forEach((card) => {
card.style.pointerEvents = "none";
card.style.opacity = "0.5";
});
}
}

// ========== EMAIL & SUBJECT SELECTION ==========

function submitInfoAndStartTimer() {
const name = document.getElementById("studentName").value.trim();
const email = document.getElementById("studentEmail").value.trim();
const truong = document.getElementById("studentTruong").value.trim();
const khoa = document.getElementById("studentKhoa").value.trim();
const lop = document.getElementById("studentLop").value.trim();
const khoaHoc = document.getElementById("studentKhoaHoc").value.trim();

if (!name || !email || !truong || !khoa || !lop || !khoaHoc) {
alert("⚠️ Vui lòng nhập đầy đủ thông tin");
return;
}

const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
alert("⚠️ Email không hợp lệ");
return;
}

studentInfo = { name, email, truong, khoa, lop, khoaHoc };

document.getElementById("checkEmailBtn").style.display = "none";
document.getElementById("countdownContainerSelection").style.display =
"block";
document.getElementById("subjectSelectorContainer").style.display =
"block";
document.getElementById("startBtn").style.display = "block";

emailConfirmed = true;

// Kiểm tra timer cũ trong localStorage
const savedTimer = loadTimerFromStorage(email);

if (!countdownStarted) {
if (savedTimer && savedTimer.remainingTime > 0) {
// Tiếp tục từ timer cũ
remainingTime = savedTimer.remainingTime;
startTime =
Date.now() -
(TIMER_CONFIG.quizTimeSeconds - remainingTime) * 1000;
console.log("⏱️ Tiếp tục timer - Còn lại:", remainingTime, "giây");

alert(
`⏰ Bạn còn ${Math.floor(remainingTime / 60)} phút ${remainingTime % 60} giây để hoàn thành các bộ đề!`,
);
} else {
// Bắt đầu timer mới
console.log("⏱️ Bắt đầu timer mới");
}

startCountdown();
countdownStarted = true;
}

checkAvailableSubjects();
}

function checkAvailableSubjects() {
const email = document.getElementById("studentEmail").value.trim();

if (!email) {
document.getElementById("subjectGrid").innerHTML = `
<div style="text-align: center; color: #666; font-style: italic;">
📧 Vui lòng nhập email để kiểm tra bộ đề có sẵn
</div>
`;
updateStartButton();
return;
}

document.getElementById("subjectGrid").innerHTML = `
<div class="loading">
<div class="spinner"></div>
<p>Đang kiểm tra bộ đề có sẵn...</p>
</div>
`;

google.script.run
.withSuccessHandler(function (result) {
displaySubjects(result);

// ✅ Kiểm tra nếu đã hoàn thành HẾT bộ đề → DỪNG TIMER
if (
result &&
result.available &&
result.available.length === 0 &&
result.completed &&
result.completed.length > 0
) {
console.log("🎉 Đã hoàn thành tất cả bộ đề - DỪNG TIMER");

if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}

clearTimerFromStorage();

// Ẩn timer display
const timerContainer = document.getElementById(
"countdownContainerSelection",
);
if (timerContainer) {
timerContainer.style.display = "none";
}

alert(
"🎉 Chúc mừng! Bạn đã hoàn thành tất cả bộ đề. Timer đã dừng.",
);
}
})
.withFailureHandler(handleError)
.getAvailableSubjects(email);
}

function displaySubjects(result) {
const container = document.getElementById("subjectGrid");
container.innerHTML = "";

if (!result || !result.available) {
container.innerHTML =
'<div class="error">Có lỗi khi tải danh sách bộ đề.</div>';
return;
}

const {
available,
completed,
total,
scores,
averageScore,
totalSubjects,
} = result;

if (completed.length > 0) {
const scoresSummaryDiv = document.createElement("div");
scoresSummaryDiv.className = "score-summary";

scoresSummaryDiv.innerHTML = `
<h4>📊 Kết quả học tập của bạn</h4>
<div class="average-score">${averageScore}%</div>
<p style="color: #666; margin-bottom: 15px;">Điểm trung bình trên ${totalSubjects} bộ đề đã hoàn thành</p>

<div class="scores-grid">
${scores
.map(
(score) => `
<div class="score-card">
<h5>${score.subject}</h5>
<div class="score-value">${score.score}/${score.total}</div>
<div class="score-percentage">${score.percentage}%</div>
</div>
`,
)
.join("")}
</div>
`;
container.appendChild(scoresSummaryDiv);

const statsDiv = document.createElement("div");
statsDiv.style.cssText = `
background: #e8f5e8;
border: 1px solid #c3e6c3;
border-radius: 8px;
padding: 15px;
margin-bottom: 20px;
text-align: center;
`;
statsDiv.innerHTML = `
<h4 style="color: #2d5a2d; margin-bottom: 10px;">📈 Tiến độ học tập</h4>
<p style="color: #2d5a2d; margin: 5px 0;">✅ Đã hoàn thành: <strong>${completed.length}/${total}</strong> bộ đề</p>
<p style="color: #2d5a2d; margin: 5px 0;">📝 Còn lại: <strong>${available.length}</strong> bộ đề</p>
`;
container.appendChild(statsDiv);
}

if (available.length === 0) {
const messageDiv = document.createElement("div");
messageDiv.style.cssText = `
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 8px;
padding: 20px;
text-align: center;
color: #856404;
`;
messageDiv.innerHTML = `
<h4>🎉 Chúc mừng!</h4>
<p style="margin: 10px 0;">Bạn đã hoàn thành tất cả ${total} bộ đề.</p>
<p style="font-size: 0.9em; color: #666;">Điểm trung bình: <strong>${averageScore}%</strong></p>
`;
container.appendChild(messageDiv);
updateStartButton();
return;
}

const gridDiv = document.createElement("div");
gridDiv.className = "subject-grid";

const icons = [
"📘",
"📗",
"📙",
"📕",
"📓",
"📔",
"📒",
"📰",
"📚",
"📖",
];

available.forEach((subject, index) => {
const card = document.createElement("div");
card.className = "subject-card";

const icon = icons[index % icons.length];

card.innerHTML = `
<span class="icon">${icon}</span>
<h4>${subject.displayName}</h4>
<p>Mã: ${subject.code}</p>
`;
card.onclick = () => selectSubject(subject.code, card);
gridDiv.appendChild(card);
});

container.appendChild(gridDiv);
}

function selectSubject(subjectCode, cardElement) {
document.querySelectorAll(".subject-card").forEach((card) => {
card.classList.remove("selected");
});

cardElement.classList.add("selected");
selectedSubject = subjectCode;
updateStartButton();
}

function updateStartButton() {
const startBtn = document.getElementById("startBtn");
const email = document.getElementById("studentEmail").value.trim();

if (selectedSubject && email) {
startBtn.disabled = false;
startBtn.innerHTML = `🚀 Bắt đầu làm bài: <strong>${selectedSubject}</strong>`;
} else if (!email) {
startBtn.disabled = true;
startBtn.innerHTML = "📧 Vui lòng nhập email trước";
} else {
startBtn.disabled = true;
startBtn.innerHTML = "🚀 Vui lòng chọn bộ đề";
}
}

// ========== QUIZ FUNCTIONS ==========

function startQuiz() {
if (!emailConfirmed) {
alert("⚠️ Vui lòng xác nhận email trước khi bắt đầu làm bài");
return;
}

const name = document.getElementById("studentName").value.trim();
const email = document.getElementById("studentEmail").value.trim();

if (!name || !email || !selectedSubject) {
alert("⚠️ Vui lòng nhập đầy đủ thông tin và chọn bộ đề");
return;
}

document.getElementById("subjectSelection").style.display = "none";
document.getElementById("loading").style.display = "block";

studentInfo.subject = selectedSubject;

google.script.run
.withSuccessHandler(loadQuestions)
.withFailureHandler(handleError)
.getRandomQuestions(selectedSubject);
}

function loadQuestions(data) {
questions = data;
document.getElementById("loading").style.display = "none";

if (!questions || questions.length === 0) {
showError(`Không thể tải câu hỏi từ bộ đề "${selectedSubject}"`);
return;
}

document.getElementById("currentSubjectName").textContent =
`📋 Bộ đề: ${selectedSubject}`;
renderQuestions();
document.getElementById("quiz").style.display = "block";

// Timer đã chạy từ lúc xác nhận thông tin, không cần start lại
}

function renderQuestions() {
const container = document.getElementById("questions");
container.innerHTML = "";

questions.forEach((q, index) => {
const questionDiv = document.createElement("div");
questionDiv.className = "question unanswered";
questionDiv.id = `question-${q.id}`;

const inputType = q.isMultiple ? "checkbox" : "radio";
const inputName = `question_${q.id}`;

let optionsHTML = "";
const options = [
{ value: "A", text: q.optionA },
{ value: "B", text: q.optionB },
{ value: "C", text: q.optionC },
{ value: "D", text: q.optionD },
{ value: "E", text: q.optionE },
{ value: "F", text: q.optionF },
{ value: "G", text: q.optionG },
{ value: "H", text: q.optionH },
{ value: "I", text: q.optionI },
{ value: "J", text: q.optionJ },
];

options.forEach((option) => {
if (option.text && option.text.trim()) {
optionsHTML += `
<div class="option" onclick="selectOption(this)">
<input type="${inputType}" id="q${q.id}_${option.value.toLowerCase()}" name="${inputName}" value="${option.value}" onchange="checkAnswerStatus(${q.id})">
<label for="q${q.id}_${option.value.toLowerCase()}">${option.value}. ${option.text}</label>
</div>
`;
}
});

questionDiv.innerHTML = `
<h3>Câu ${index + 1}: ${q.question}</h3>
<div class="options-grid">
${optionsHTML}
</div>
`;

container.appendChild(questionDiv);
});

updateSubmitButton();
}

function checkAnswerStatus(questionId) {
const inputs = document.querySelectorAll(
`input[name="question_${questionId}"]:checked`,
);
const questionDiv = document.getElementById(`question-${questionId}`);

if (inputs.length > 0) {
questionDiv.classList.remove("unanswered");
questionDiv.classList.add("answered");
} else {
questionDiv.classList.remove("answered");
questionDiv.classList.add("unanswered");
}

updateSubmitButton();
}

function updateSubmitButton() {
const submitBtn = document.getElementById("submitBtn");
const warningMessage = document.getElementById("warningMessage");
const unansweredQuestions = document.querySelectorAll(
".question.unanswered",
).length;

if (unansweredQuestions === 0) {
submitBtn.disabled = false;
submitBtn.className = "btn";
submitBtn.innerHTML = "✅ Nộp bài";
warningMessage.style.display = "none";
} else {
submitBtn.disabled = false;
submitBtn.className = "btn";
submitBtn.innerHTML = `⚠️ Nộp bài (Còn ${unansweredQuestions} câu chưa làm)`;
warningMessage.style.display = "block";
}
}

function selectOption(optionElement) {
const input = optionElement.querySelector("input");

if (input.type === "radio") {
const name = input.name;
document.querySelectorAll(`input[name="${name}"]`).forEach((inp) => {
inp.closest(".option").classList.remove("selected");
});
input.checked = true;
optionElement.classList.add("selected");
} else {
input.checked = !input.checked;
if (input.checked) {
optionElement.classList.add("selected");
} else {
optionElement.classList.remove("selected");
}
}

input.dispatchEvent(new Event("change"));
}

document
.getElementById("quizForm")
.addEventListener("submit", function (e) {
e.preventDefault();
submitQuiz(false);
});

function submitQuiz(isAutoSubmit = false) {
const unansweredQuestions = document.querySelectorAll(
".question.unanswered",
).length;

if (!isAutoSubmit && unansweredQuestions > 0) {
if (
!confirm(
`⚠️ Còn ${unansweredQuestions} câu hỏi chưa được trả lời. Bạn có chắc muốn nộp bài không?`,
)
) {
return false;
}
}

// KHÔNG DỪNG TIMER - để tiếp tục làm bộ đề khác
// Timer chỉ dừng khi hết giờ hoặc hoàn thành tất cả bộ đề

const answers = [];
let totalScore = 0;

questions.forEach((q) => {
const inputs = document.querySelectorAll(
`input[name="question_${q.id}"]:checked`,
);
const selectedAnswers = Array.from(inputs).map(
(input) => input.value,
);
const selectedAnswer = selectedAnswers.join(",");

const selectedAnswerContent = Array.from(inputs).map((input) => {
const label = document.querySelector(`label[for="${input.id}"]`);
return label
? label.textContent.trim()
: `${input.value}. (Không tìm thấy)`;
});
const selectedAnswerText = selectedAnswerContent.join(" | ");

const correctAnswers = q.correctAnswer
.split(",")
.map((ans) => ans.trim());
const correctAnswerContent = correctAnswers.map((letter) => {
const optionKey = `option${letter}`;
return q[optionKey] ? `${letter}. ${q[optionKey]}` : letter;
});
const correctAnswerText = correctAnswerContent.join(" | ");

let isCorrect = false;

if (q.isMultiple) {
isCorrect =
selectedAnswers.length === correctAnswers.length &&
selectedAnswers.every((ans) => correctAnswers.includes(ans));
} else {
isCorrect = selectedAnswer === q.correctAnswer;
}

const score = isCorrect ? 1 : 0;
totalScore += score;

answers.push({
questionId: q.id,
question: q.question,
selectedAnswer: selectedAnswerText || "(Không chọn)",
correctAnswer: q.correctAnswer,
correctAnswerText: correctAnswerText,
score: score,
displayedOptions: q.displayedOptions || "",
});
});

const timeUsed = getTimeUsed();

const results = {
name: studentInfo.name,
email: studentInfo.email,
truong: studentInfo.truong,
khoa: studentInfo.khoa,
lop: studentInfo.lop,
khoaHoc: studentInfo.khoaHoc,
subject: studentInfo.subject,
answers: answers,
totalScore: totalScore,
timeUsed: timeUsed ? timeUsed.formatted : null,
};

document.getElementById("quiz").style.display = "none";
document.getElementById("loading").style.display = "block";

lastCompletedSubject = studentInfo.subject;

google.script.run
.withSuccessHandler(showResults)
.withFailureHandler(handleError)
.saveResults(results);
}

function showResults(response) {
document.getElementById("loading").style.display = "none";

if (response.success) {
document.getElementById("finalScore").textContent =
response.totalScore + "/" + response.maxScore;
document.getElementById("scoreDetail").textContent =
`Bạn đã trả lời đúng ${response.totalScore} trong ${response.maxScore} câu hỏi`;

let resultHTML = `<strong>✅ Hoàn thành bộ đề: ${response.subject}</strong><br>`;

if (TIMER_CONFIG.showTimeUsed && response.timeUsed) {
const timeUsed = getTimeUsed();
const minutesRemaining = Math.floor(remainingTime / 60);
const secondsRemaining = remainingTime % 60;

resultHTML += `⏱️ Thời gian sử dụng: ${timeUsed.formatted}<br>`;
resultHTML += `⏳ Thời gian còn lại: ${minutesRemaining} phút ${secondsRemaining} giây<br>`;

// Kiểm tra nếu còn thời gian
if (remainingTime > 0) {
resultHTML += `<br>💡 Bạn vẫn còn thời gian để làm bộ đề khác!`;
} else {
resultHTML += `<br>⏰ Đã hết thời gian làm bài!`;
}
}

resultHTML += `<br>Kết quả đã được lưu vào hệ thống.`;

document.getElementById("resultInfo").innerHTML = resultHTML;
document.getElementById("result").style.display = "block";
} else {
showError("Có lỗi khi lưu kết quả: " + response.error);
}
}

function returnToSelection() {
selectedSubject = "";
questions = [];

// KHÔNG reset timer - Timer tiếp tục chạy cho bộ đề tiếp theo

document.getElementById("result").style.display = "none";
document.getElementById("subjectSelection").style.display = "block";

// CẬP NHẬT LẠI DANH SÁCH BỘ ĐỀ (fix lỗi không cập nhật)
const email = document.getElementById("studentEmail").value.trim();
if (email) {
console.log("🔄 Cập nhật danh sách bộ đề sau khi nộp bài");
checkAvailableSubjects();
}

updateStartButton();
}

// ========== LEADERBOARD FUNCTIONS (HOÀN CHỈNH) ==========

function showLeaderboard() {
document.getElementById("subjectSelection").style.display = "none";
document.getElementById("quiz").style.display = "none";
document.getElementById("result").style.display = "none";
document.getElementById("error").style.display = "none";
document.getElementById("leaderboard").style.display = "block";

loadSubjectFilter();
switchLeaderboardTab("overall");
}

function hideLeaderboard() {
document.getElementById("leaderboard").style.display = "none";
document.getElementById("subjectSelection").style.display = "block";

const email = document.getElementById("studentEmail").value.trim();
if (email) {
checkAvailableSubjects();
}
}

function loadSubjectFilter() {
google.script.run
.withSuccessHandler(function (subjects) {
const select = document.getElementById("subjectFilter");
select.innerHTML = '<option value="">-- Chọn bộ đề --</option>';

subjects.forEach((subject) => {
const option = document.createElement("option");
option.value = subject.code;
option.textContent = subject.displayName;
select.appendChild(option);
});
})
.withFailureHandler(handleError)
.getSubjectsWithResults();
}

function switchLeaderboardTab(tab) {
currentLeaderboardTab = tab;

document.getElementById("tabOverall").classList.remove("active");
document.getElementById("tabSubject").classList.remove("active");

if (tab === "overall") {
document.getElementById("tabOverall").classList.add("active");
document.getElementById("subjectFilterContainer").style.display =
"none";
loadOverallLeaderboard();
} else {
document.getElementById("tabSubject").classList.add("active");
document.getElementById("subjectFilterContainer").style.display =
"block";

const selectedSubjectFilter =
document.getElementById("subjectFilter").value;
if (selectedSubjectFilter) {
loadSubjectLeaderboard();
} else {
document.getElementById("leaderboardStats").innerHTML = "";
document.getElementById("leaderboardContent").innerHTML = `
<div class="no-data">
<div class="icon">📚</div>
<p>Vui lòng chọn bộ đề để xem bảng xếp hạng</p>
</div>
`;
}
}
}

function loadOverallLeaderboard() {
const currentEmail = document
.getElementById("studentEmail")
.value.trim();

document.getElementById("leaderboardContent").innerHTML = `
<div class="loading">
<div class="spinner"></div>
<p>Đang tải bảng xếp hạng...</p>
</div>
`;

google.script.run
.withSuccessHandler(displayOverallLeaderboard)
.withFailureHandler(handleError)
.getOverallLeaderboard(currentEmail);
}

function loadSubjectLeaderboard() {
const subjectCode = document.getElementById("subjectFilter").value;
const currentEmail = document
.getElementById("studentEmail")
.value.trim();

if (!subjectCode) {
document.getElementById("leaderboardStats").innerHTML = "";
document.getElementById("leaderboardContent").innerHTML = `
<div class="no-data">
<div class="icon">📚</div>
<p>Vui lòng chọn bộ đề để xem bảng xếp hạng</p>
</div>
`;
return;
}

document.getElementById("leaderboardContent").innerHTML = `
<div class="loading">
<div class="spinner"></div>
<p>Đang tải bảng xếp hạng...</p>
</div>
`;

google.script.run
.withSuccessHandler(displaySubjectLeaderboard)
.withFailureHandler(handleError)
.getSubjectLeaderboard(subjectCode, currentEmail);
}

function displayOverallLeaderboard(result) {
console.log("📊 Overall Leaderboard result:", result); // Debug log

// KIỂM TRA NULL HOẶC UNDEFINED
if (!result) {
document.getElementById("leaderboardContent").innerHTML = `
<div class="error">
❌ Không thể tải dữ liệu bảng xếp hạng.<br>
Lỗi: Server trả về null.<br>
Vui lòng kiểm tra Code.gs có hàm getOverallLeaderboard() chưa.
</div>
`;
return;
}

if (!result.success) {
document.getElementById("leaderboardContent").innerHTML = `
<div class="error">${result.error || "Có lỗi xảy ra"}</div>
`;
return;
}

const { data, totalParticipants, totalSubjects } = result;

document.getElementById("leaderboardStats").innerHTML = `
<div class="stat-card">
<div class="stat-icon">👥</div>
<div class="stat-value">${totalParticipants || 0}</div>
<div class="stat-label">Tổng thí sinh</div>
</div>
<div class="stat-card">
<div class="stat-icon">📚</div>
<div class="stat-value">${totalSubjects || 0}</div>
<div class="stat-label">Tổng bộ đề</div>
</div>
<div class="stat-card">
<div class="stat-icon">🏆</div>
<div class="stat-value">${data && data.length > 0 ? data[0].percentage + "%" : "-"}</div>
<div class="stat-label">Điểm cao nhất</div>
</div>
`;

if (!data || data.length === 0) {
document.getElementById("leaderboardContent").innerHTML = `
<div class="no-data">
<div class="icon">📊</div>
<p>Chưa có dữ liệu xếp hạng</p>
</div>
`;
return;
}

let tableHTML = `
<table class="leaderboard-table">
<thead>
<tr>
<th>Hạng</th>
<th>Họ tên</th>
<th>Trường</th>
<th>Email</th>
<th>Số bộ đề</th>
<th>Điểm TB</th>
<th>Hoàn thành</th>
</tr>
</thead>
<tbody>
`;

data.forEach((item) => {
const rankClass = getRankClass(item.rank);
const percentageClass = getPercentageClass(item.percentage);
const currentUserClass = item.isCurrentUser ? "current-user" : "";

tableHTML += `
<tr class="${currentUserClass}">
<td>
<span class="rank-badge ${rankClass}">${item.rank}</span>
</td>
<td>${item.name} ${item.isCurrentUser ? "⭐" : ""}</td>
<td>${item.truong || "-"}</td>
<td>${item.email}</td>
<td>${item.subjectsCompleted}</td>
<td>
<div class="percentage-bar">
<div class="percentage-bar-bg">
<div class="percentage-bar-fill ${percentageClass}" style="width: ${item.percentage}%"></div>
</div>
<span class="percentage-text">${item.percentage}%</span>
</div>
</td>
<td>${item.completedAt}</td>
</tr>
`;
});

tableHTML += "</tbody></table>";
document.getElementById("leaderboardContent").innerHTML = tableHTML;
}

function displaySubjectLeaderboard(result) {
console.log("📚 Subject Leaderboard result:", result); // Debug log

// KIỂM TRA NULL HOẶC UNDEFINED
if (!result) {
document.getElementById("leaderboardContent").innerHTML = `
<div class="error">
❌ Không thể tải dữ liệu bảng xếp hạng.<br>
Lỗi: Server trả về null.<br>
Vui lòng kiểm tra Code.gs có hàm getSubjectLeaderboard() chưa.
</div>
`;
return;
}

if (!result.success) {
document.getElementById("leaderboardContent").innerHTML = `
<div class="error">${result.error || "Có lỗi xảy ra"}</div>
`;
return;
}

const { data, subject, totalParticipants } = result;

document.getElementById("leaderboardStats").innerHTML = `
<div class="stat-card">
<div class="stat-icon">📋</div>
<div class="stat-value">${subject || "-"}</div>
<div class="stat-label">Bộ đề</div>
</div>
<div class="stat-card">
<div class="stat-icon">👥</div>
<div class="stat-value">${totalParticipants || 0}</div>
<div class="stat-label">Số thí sinh</div>
</div>
<div class="stat-card">
<div class="stat-icon">🏆</div>
<div class="stat-value">${data && data.length > 0 ? data[0].percentage + "%" : "-"}</div>
<div class="stat-label">Điểm cao nhất</div>
</div>
`;

if (!data || data.length === 0) {
document.getElementById("leaderboardContent").innerHTML = `
<div class="no-data">
<div class="icon">📊</div>
<p>Chưa có ai làm bộ đề này</p>
</div>
`;
return;
}

let tableHTML = `
<table class="leaderboard-table">
<thead>
<tr>
<th>Hạng</th>
<th>Họ tên</th>
<th>Trường</th>
<th>Email</th>
<th>Điểm</th>
<th>Phần trăm</th>
<th>Hoàn thành</th>
</tr>
</thead>
<tbody>
`;

data.forEach((item) => {
const rankClass = getRankClass(item.rank);
const percentageClass = getPercentageClass(item.percentage);
const currentUserClass = item.isCurrentUser ? "current-user" : "";

tableHTML += `
<tr class="${currentUserClass}">
<td>
<span class="rank-badge ${rankClass}">${item.rank}</span>
</td>
<td>${item.name} ${item.isCurrentUser ? "⭐" : ""}</td>
<td>${item.truong || "-"}</td>
<td>${item.email}</td>
<td>${item.score}/${item.total}</td>
<td>
<div class="percentage-bar">
<div class="percentage-bar-bg">
<div class="percentage-bar-fill ${percentageClass}" style="width: ${item.percentage}%"></div>
</div>
<span class="percentage-text">${item.percentage}%</span>
</div>
</td>
<td>${item.completedAt}</td>
</tr>
`;
});

tableHTML += "</tbody></table>";
document.getElementById("leaderboardContent").innerHTML = tableHTML;
}

function getRankClass(rank) {
if (rank === 1) return "rank-1";
if (rank === 2) return "rank-2";
if (rank === 3) return "rank-3";
return "rank-other";
}

function getPercentageClass(percentage) {
if (percentage >= 80) return "percentage-excellent";
if (percentage >= 60) return "percentage-good";
if (percentage >= 40) return "percentage-average";
return "percentage-poor";
}

// ========== ERROR HANDLING ==========

function handleError(error) {
console.error("Error:", error);
showError(error.message || "Có lỗi không xác định xảy ra");
}

function showError(message) {
document.getElementById("loading").style.display = "none";
document.getElementById("quiz").style.display = "none";
document.getElementById("subjectSelection").style.display = "none";
document.getElementById("errorMessage").textContent = message;
document.getElementById("error").style.display = "block";
}

// ========== BEFOREUNLOAD WARNING ==========

window.addEventListener("beforeunload", function (e) {
// Cảnh báo nếu đang trong thời gian làm bài (timer đang chạy)
if (countdownStarted && remainingTime > 0) {
e.preventDefault();
e.returnValue =
"Bạn đang trong thời gian làm bài. Nếu thoát, bạn sẽ mất thời gian đã sử dụng. Bạn có chắc muốn thoát?";
return e.returnValue;
}
});
</script>
</body>
</html>

B6: Bấm lưu toàn bộ dự án

B7: Chọn Tùy chọn triển khai => chọn Tùy chọn triển khai mới => chọn loại Ứng Dụng Web => chọn setup người có quyền truy cập là Bất kỳ ai => chọn Ủy quyền truy cập (chỉ thực hiện lần đầu) => chọn tại khoản google đang sử dụng => chọn Allow => chọn Triển khai => copy URL của app

Tạo file index.html và triển khai

Triển khai dự án:

Triển khai dự án

setup tài khoản để triển khai

Setup tài khoản

Lúc này, khi người tham người sử dụng phần mềm bấm vào url, sẽ được dẫn đến giao diện đăng nhập vào kiểm tra Sử dụng email để đăng nhập => chọn bộ đề thi và bắt đầu kiểm tra

Hoàn tất và mở link

Kết quả kiểm tra sẽ được lưu ở sheet câu trả lời

File câu trả lời sau khi test

Giao diện app được tạo ra

Giao diện app

IV. Tóm tắt

Quy trình thực hiện gồm:

  • Tạo folder và file câu hỏi và câu trả lời.
  • Tại appscript: Tạo file code.gs, dán code tương ứng và thay đổi ID và tuy chỉnh về câu hỏi và thời gian.
  • Tại appscript: Tạo file index.html và dán code tương ứng vào file
  • Triển khai dự án.

Shop đang quản lý bảo hành bằng Excel hoặc giấy tờ thủ công? Đã đến lúc chuyển sang hệ thống bảo hành điện tử tinh gọn dành riêng cho cửa hàng điện thoại, laptop, điện máy, máy ảnh cùng Qranty.

Shop điện tử muốn biến quy trình bảo hành của shop thành trải nghiệm chuyên nghiệp hơn? Khám phá cách Qranty giúp quản lý bảo hành, khách hàng và sửa chữa trên một nền tảng duy nhất.

Shop điện tử muốn gia tăng doanh thu từ tệp khách hàng cũ? Dùng thử Qranty miễn phí để tự động hoá bảo hành điện tử và chăm sóc khách hàng bằng QR, Zalo, SMS.


V. Đọc thêm

  • Bài viết 3 (Bổ sung sau)