Skip to content

Instantly share code, notes, and snippets.

@manhbi18112005
Created September 15, 2025 15:05
Show Gist options
  • Select an option

  • Save manhbi18112005/30d8213e4dd582cccdfdaff02622a808 to your computer and use it in GitHub Desktop.

Select an option

Save manhbi18112005/30d8213e4dd582cccdfdaff02622a808 to your computer and use it in GitHub Desktop.
Grade Calculator for Examiner
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Modern Grade Calculator</title>
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<style>
/* Custom styles to hide the number input arrows */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type=number] {
-moz-appearance: textfield;
}
/* Custom style for active tab */
.tab-active {
@apply bg-indigo-100 text-indigo-700;
}
/* Dark mode variables */
:root {
--bg-primary: #f1f5f9;
--bg-secondary: #ffffff;
--text-primary: #1e293b;
--text-secondary: #64748b;
--border-color: #e2e8f0;
}
[data-theme="dark"] {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--border-color: #334155;
}
.theme-transition {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
/* Tooltip styles */
.tooltip {
position: relative;
}
.tooltip:hover::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: #1e293b;
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
z-index: 1000;
}
</style>
</head>
<body class="bg-slate-100 flex items-center justify-center min-h-screen p-4 md:p-8 theme-transition"
style="background-color: var(--bg-primary);">
<!-- Main Layout Container -->
<div class="w-full max-w-6xl mx-auto flex flex-col md:flex-row gap-8 md:items-start">
<!-- Sidebar with Tabs -->
<aside class="w-full md:w-80 lg:w-96 bg-white rounded-2xl shadow-lg flex flex-col h-[85vh] theme-transition"
style="background-color: var(--bg-secondary);">
<div class="flex border-b border-slate-200 p-6 pb-4 flex-shrink-0"
style="border-color: var(--border-color);">
<button data-tab-target="#history-panel"
class="tab-btn flex-1 py-2 text-center font-semibold text-slate-500 hover:bg-slate-100 rounded-t-lg transition-colors focus:outline-none">History</button>
<button data-tab-target="#settings-panel"
class="tab-btn flex-1 py-2 text-center font-semibold text-slate-500 hover:bg-slate-100 rounded-t-lg transition-colors focus:outline-none">Settings</button>
<button data-tab-target="#goal-panel"
class="tab-btn flex-1 py-2 text-center font-semibold text-slate-500 hover:bg-slate-100 rounded-t-lg transition-colors focus:outline-none">Goal</button>
</div>
<div class="flex-grow min-h-0 px-6 pb-6">
<!-- History Panel -->
<div id="history-panel" class="tab-panel h-full flex flex-col">
<div class="flex items-center justify-between my-4 flex-shrink-0">
<h2 class="text-xl font-bold text-slate-700" style="color: var(--text-primary);">Grade History
</h2>
<div class="flex gap-2">
<button id="export-history"
class="tooltip text-sm text-indigo-500 hover:text-indigo-700 font-semibold focus:outline-none"
data-tooltip="Export history (Ctrl+E)">Export</button>
<button id="import-history"
class="tooltip text-sm text-indigo-500 hover:text-indigo-700 font-semibold focus:outline-none"
data-tooltip="Import history">Import</button>
<button id="clear-history"
class="tooltip text-sm text-indigo-500 hover:text-indigo-700 font-semibold focus:outline-none"
data-tooltip="Clear all (Ctrl+X)">Clear</button>
</div>
</div>
<ul id="history-list" class="space-y-3 flex-grow overflow-y-auto pr-2">
</ul>
<input type="file" id="import-file" accept=".json" class="hidden">
</div>
<!-- Settings Panel -->
<div id="settings-panel" class="tab-panel h-full hidden overflow-y-auto pr-2">
<h2 class="text-xl font-bold text-slate-700 mb-4" style="color: var(--text-primary);">Settings</h2>
<div class="space-y-6">
<div>
<label for="total-questions-setting" class="block text-sm font-medium text-slate-600 mb-1"
style="color: var(--text-secondary);">Total Questions</label>
<input type="number" id="total-questions-setting" min="1"
class="w-full p-2 border border-slate-300 rounded-md focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 theme-transition"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border-color);">
</div>
<div>
<label for="grade-view-setting" class="block text-sm font-medium text-slate-600 mb-1"
style="color: var(--text-secondary);">Grade Display</label>
<select id="grade-view-setting"
class="w-full p-2 border border-slate-300 rounded-md focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 theme-transition"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border-color);">
<option value="percentage">Percentage (100%)</option>
<option value="points">Points (out of 10)</option>
</select>
</div>
<div>
<label for="decimal-places-setting" class="block text-sm font-medium text-slate-600 mb-1"
style="color: var(--text-secondary);">Decimal Places</label>
<input type="number" id="decimal-places-setting" min="0" max="5"
class="w-full p-2 border border-slate-300 rounded-md focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 theme-transition"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border-color);">
</div>
<div>
<label class="block text-sm font-medium text-slate-600 mb-1"
style="color: var(--text-secondary);">Letter Grades</label>
<div class="flex items-center">
<input type="checkbox" id="show-letter-grades" class="mr-2">
<label for="show-letter-grades" class="text-sm"
style="color: var(--text-secondary);">Show letter grades</label>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-600 mb-1"
style="color: var(--text-secondary);">Theme</label>
<div class="flex items-center">
<input type="checkbox" id="dark-mode-toggle" class="mr-2">
<label for="dark-mode-toggle" class="text-sm" style="color: var(--text-secondary);">Dark
mode</label>
</div>
</div>
<div class="text-xs text-slate-400 p-3 bg-slate-50 rounded-lg theme-transition"
style="background-color: var(--bg-primary); color: var(--text-secondary);">
<strong>Keyboard Shortcuts:</strong><br>
Enter: Add to history<br>
Ctrl+E: Export history<br>
Ctrl+X: Clear history<br>
Ctrl+D: Toggle dark mode
</div>
</div>
</div>
<!-- Grade Goal Panel -->
<div id="goal-panel" class="tab-panel h-full hidden overflow-y-auto pr-2">
<h2 class="text-xl font-bold text-slate-700 mb-4" style="color: var(--text-primary);">Grade Goal
</h2>
<div class="space-y-4">
<div>
<label for="target-grade" class="block text-sm font-medium text-slate-600 mb-1"
style="color: var(--text-secondary);">Target Grade (%)</label>
<input type="number" id="target-grade" min="0" max="100" placeholder="e.g., 85"
class="w-full p-2 border border-slate-300 rounded-md focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 theme-transition"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border-color);">
</div>
<div id="goal-result" class="mt-4 p-4 bg-slate-50 rounded-lg theme-transition"
style="background-color: var(--bg-primary);">
Enter a target grade to see how many questions you can get wrong.
</div>
</div>
</div>
</div>
</aside>
<!-- Calculator Section -->
<main
class="w-full md:flex-1 bg-white p-8 rounded-2xl shadow-lg text-center h-[85vh] flex flex-col justify-center theme-transition"
style="background-color: var(--bg-secondary);">
<h1 class="text-3xl md:text-4xl font-bold text-slate-800" style="color: var(--text-primary);">Grade
Calculator</h1>
<p id="total-questions-info" class="text-slate-500 mt-2 mb-8" style="color: var(--text-secondary);">The test
has a total of 30 questions.</p>
<label for="wrong-questions" class="text-slate-600 font-medium sr-only">Enter the number of WRONG
questions:</label>
<input type="number" id="wrong-questions" placeholder="e.g., 5" min="0" max="30"
class="w-full p-4 border border-slate-300 rounded-lg text-4xl text-center font-mono focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-shadow duration-200 theme-transition"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border-color);">
<div id="result" class="mt-6 font-bold min-h-[90px] text-slate-800 transition-opacity duration-300"
style="color: var(--text-primary);">
</div>
<h2 class="text-xl font-bold text-slate-700 mb-4" style="color: var(--text-primary);">Statistics
</h2>
<div id="stats-content" class="space-y-4">
<div class="text-center text-slate-400" style="color: var(--text-secondary);">No data available
yet.</div>
</div>
</main>
</div>
<script>
const elements = {
wrongInput: document.getElementById('wrong-questions'),
resultDiv: document.getElementById('result'),
historyList: document.getElementById('history-list'),
clearHistoryBtn: document.getElementById('clear-history'),
exportHistoryBtn: document.getElementById('export-history'),
importHistoryBtn: document.getElementById('import-history'),
importFile: document.getElementById('import-file'),
totalQuestionsInfo: document.getElementById('total-questions-info'),
totalQuestionsSettingInput: document.getElementById('total-questions-setting'),
gradeViewSettingSelect: document.getElementById('grade-view-setting'),
decimalPlacesSettingInput: document.getElementById('decimal-places-setting'),
showLetterGradesCheckbox: document.getElementById('show-letter-grades'),
darkModeToggle: document.getElementById('dark-mode-toggle'),
targetGradeInput: document.getElementById('target-grade'),
goalResult: document.getElementById('goal-result'),
statsContent: document.getElementById('stats-content'),
tabBtns: document.querySelectorAll('.tab-btn'),
tabPanels: document.querySelectorAll('.tab-panel')
};
let gradeHistory = [];
let settings = {
totalQuestions: 30,
gradeView: 'percentage',
decimalPlaces: 2,
showLetterGrades: false,
darkMode: false
};
const gradeScale = [
{ min: 97, letter: 'A+' },
{ min: 93, letter: 'A' },
{ min: 90, letter: 'A-' },
{ min: 87, letter: 'B+' },
{ min: 83, letter: 'B' },
{ min: 80, letter: 'B-' },
{ min: 77, letter: 'C+' },
{ min: 73, letter: 'C' },
{ min: 70, letter: 'C-' },
{ min: 67, letter: 'D+' },
{ min: 63, letter: 'D' },
{ min: 60, letter: 'D-' },
{ min: 0, letter: 'F' }
];
const debounce = (func, delay) => {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(null, args), delay);
};
};
const Storage = {
save() {
localStorage.setItem('gradeHistory', JSON.stringify(gradeHistory));
localStorage.setItem('gradeSettings', JSON.stringify(settings));
},
load() {
const savedHistory = localStorage.getItem('gradeHistory');
const savedSettings = localStorage.getItem('gradeSettings');
if (savedHistory) gradeHistory = JSON.parse(savedHistory);
if (savedSettings) Object.assign(settings, JSON.parse(savedSettings));
}
};
function getLetterGrade(percentage) {
return gradeScale.find(grade => percentage >= grade.min)?.letter || 'F';
}
function calculateGrade() {
const numWrongStr = elements.wrongInput.value;
if (!numWrongStr) return null;
const numWrong = parseInt(numWrongStr);
if (isNaN(numWrong) || numWrong < 0 || numWrong > settings.totalQuestions) {
return { error: true };
}
const correctAnswers = settings.totalQuestions - numWrong;
const percentage = (correctAnswers / settings.totalQuestions) * 100;
const finalGrade = settings.gradeView === 'points' ? (percentage / 10) : percentage;
return {
grade: finalGrade.toFixed(settings.decimalPlaces),
percentage: percentage,
letterGrade: getLetterGrade(percentage),
correct: correctAnswers,
wrong: numWrong,
total: settings.totalQuestions,
error: false
};
}
function updateDisplay() {
const data = calculateGrade();
if (!data) {
elements.resultDiv.innerHTML = '';
return;
}
if (data.error) {
elements.resultDiv.innerHTML = `<span class="text-lg text-red-500 font-medium">Please enter a number between 0 and ${settings.totalQuestions}.</span>`;
return;
}
const gradeText = settings.gradeView === 'points'
? `<div class="text-5xl">${data.grade} <span class="text-3xl text-slate-400">/ 10</span></div>`
: `<div class="text-5xl">${data.grade}%</div>`;
const letterGradeText = settings.showLetterGrades
? `<div class="text-3xl font-bold text-indigo-600 mt-2">${data.letterGrade}</div>`
: '';
elements.resultDiv.innerHTML = `
${gradeText}
${letterGradeText}
<div class="text-2xl font-normal text-slate-500 mt-2">
<span class="text-green-500"><strong>${data.correct}</strong> Correct</span>
-
<span class="text-red-500"><strong>${data.wrong}</strong> Wrong</span>
</div>
`;
}
function calculateGoal() {
const targetGrade = parseFloat(elements.targetGradeInput.value);
if (isNaN(targetGrade) || targetGrade < 0 || targetGrade > 100) {
elements.goalResult.innerHTML = 'Please enter a valid target grade between 0 and 100.';
return;
}
const requiredCorrect = Math.ceil((targetGrade / 100) * settings.totalQuestions);
const allowedWrong = settings.totalQuestions - requiredCorrect;
elements.goalResult.innerHTML = `
<div class="text-lg font-semibold" style="color: var(--text-primary);">
To achieve ${targetGrade}%:
</div>
<div class="mt-2" style="color: var(--text-secondary);">
You need <strong class="text-green-600">${requiredCorrect}</strong> correct answers<br>
You can get <strong class="text-red-600">${Math.max(0, allowedWrong)}</strong> questions wrong
</div>
`;
}
function updateStatistics() {
if (gradeHistory.length === 0) {
elements.statsContent.innerHTML = '<div class="text-center text-slate-400" style="color: var(--text-secondary);">No data available yet.</div>';
return;
}
const grades = gradeHistory.map(item => (item.correct / item.total));
const average = grades.reduce((a, b) => a + b, 0) / grades.length;
const highest = Math.max(...grades);
const lowest = Math.min(...grades);
const avgLetterGrade = getLetterGrade(average);
elements.statsContent.innerHTML = `
<div class="grid grid-cols-2 gap-4">
<div class="text-center p-4 bg-slate-50 rounded-lg theme-transition" style="background-color: var(--bg-primary);">
<div class="text-2xl font-bold text-indigo-600">${(average * 10).toFixed(1)}</div>
<div class="text-sm" style="color: var(--text-secondary);">Average (${avgLetterGrade})</div>
</div>
<div class="text-center p-4 bg-slate-50 rounded-lg theme-transition" style="background-color: var(--bg-primary);">
<div class="text-2xl font-bold text-green-600">${(highest * 10).toFixed(1)}</div>
<div class="text-sm" style="color: var(--text-secondary);">Highest</div>
</div>
<div class="text-center p-4 bg-slate-50 rounded-lg theme-transition" style="background-color: var(--bg-primary);">
<div class="text-2xl font-bold text-red-600">${(lowest * 10).toFixed(1)}</div>
<div class="text-sm" style="color: var(--text-secondary);">Lowest</div>
</div>
<div class="text-center p-4 bg-slate-50 rounded-lg theme-transition" style="background-color: var(--bg-primary);">
<div class="text-2xl font-bold" style="color: var(--text-primary);">${gradeHistory.length}</div>
<div class="text-sm" style="color: var(--text-secondary);">Total Tests</div>
</div>
</div>
`;
}
function exportHistory() {
const data = {
history: gradeHistory,
settings: settings,
exportDate: new Date().toISOString()
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `grade-history-${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
}
function importHistory(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function (e) {
try {
const data = JSON.parse(e.target.result);
if (data.history && Array.isArray(data.history)) {
gradeHistory = data.history;
if (data.settings) {
Object.assign(settings, data.settings);
updateSettingsUI();
}
renderHistory();
updateStatistics();
alert('History imported successfully!');
} else {
alert('Invalid file format.');
}
} catch (error) {
alert('Error reading file.');
}
};
reader.readAsText(file);
}
function toggleTheme() {
settings.darkMode = !settings.darkMode;
document.documentElement.setAttribute('data-theme', settings.darkMode ? 'dark' : 'light');
elements.darkModeToggle.checked = settings.darkMode;
Storage.save();
}
function renderHistory() {
const fragment = document.createDocumentFragment();
if (gradeHistory.length === 0) {
elements.historyList.innerHTML = `<p class="text-slate-400 text-center italic mt-4" style="color: var(--text-secondary);">No history yet.</p>`;
return;
}
gradeHistory.forEach(item => {
const li = document.createElement('li');
li.className = 'flex justify-between items-center p-3 bg-slate-50 rounded-lg hover:bg-slate-100 transition-colors cursor-pointer theme-transition';
li.style.backgroundColor = 'var(--bg-primary)';
const letterGradeText = settings.showLetterGrades && item.letterGrade
? ` (${item.letterGrade})`
: '';
li.innerHTML = `
<span class="text-slate-600" style="color: var(--text-secondary);">${item.wrong} Wrong (${item.correct}/${item.total})</span>
<span class="font-bold text-slate-800 text-lg" style="color: var(--text-primary);">${item.grade}${letterGradeText}</span>
`;
li.addEventListener('click', () => {
elements.wrongInput.value = item.wrong;
updateDisplay();
});
fragment.appendChild(li);
});
elements.historyList.innerHTML = '';
elements.historyList.appendChild(fragment);
Storage.save();
}
function updateSettingsUI() {
elements.totalQuestionsSettingInput.value = settings.totalQuestions;
elements.gradeViewSettingSelect.value = settings.gradeView;
elements.decimalPlacesSettingInput.value = settings.decimalPlaces;
elements.showLetterGradesCheckbox.checked = settings.showLetterGrades;
elements.darkModeToggle.checked = settings.darkMode;
elements.totalQuestionsInfo.textContent = `The test has a total of ${settings.totalQuestions} questions.`;
elements.wrongInput.max = settings.totalQuestions;
document.documentElement.setAttribute('data-theme', settings.darkMode ? 'dark' : 'light');
}
function addToHistory() {
const data = calculateGrade();
if (!data?.error) {
const historyItem = {
...data,
grade: settings.gradeView === 'points' ? `${data.grade} / 10` : `${data.grade}%`,
timestamp: new Date().toISOString()
};
gradeHistory.unshift(historyItem);
renderHistory();
updateStatistics();
elements.wrongInput.value = '';
elements.resultDiv.innerHTML = '';
}
}
function handleTabClick(event) {
const clickedTab = event.currentTarget;
const targetPanelId = clickedTab.dataset.tabTarget;
elements.tabBtns.forEach(btn => btn.classList.remove('tab-active'));
clickedTab.classList.add('tab-active');
elements.tabPanels.forEach(panel => {
panel.classList.toggle('hidden', `#${panel.id}` !== targetPanelId);
});
}
const debouncedUpdateDisplay = debounce(updateDisplay, 100);
const debouncedCalculateGoal = debounce(calculateGoal, 300);
function setupEventListeners() {
elements.wrongInput.addEventListener('input', debouncedUpdateDisplay);
elements.wrongInput.addEventListener('keyup', e => e.key === 'Enter' && addToHistory());
elements.clearHistoryBtn.addEventListener('click', () => {
gradeHistory = [];
renderHistory();
updateStatistics();
});
elements.exportHistoryBtn.addEventListener('click', exportHistory);
elements.importHistoryBtn.addEventListener('click', () => elements.importFile.click());
elements.importFile.addEventListener('change', importHistory);
elements.targetGradeInput.addEventListener('input', debouncedCalculateGoal);
elements.totalQuestionsSettingInput.addEventListener('input', e => {
const value = parseInt(e.target.value);
if (value > 0) {
settings.totalQuestions = value;
updateSettingsUI();
updateDisplay();
calculateGoal();
Storage.save();
}
});
elements.gradeViewSettingSelect.addEventListener('change', e => {
settings.gradeView = e.target.value;
updateDisplay();
renderHistory();
Storage.save();
});
elements.decimalPlacesSettingInput.addEventListener('input', e => {
const value = parseInt(e.target.value);
if (value >= 0 && value <= 5) {
settings.decimalPlaces = value;
updateDisplay();
Storage.save();
}
});
elements.showLetterGradesCheckbox.addEventListener('change', e => {
settings.showLetterGrades = e.target.checked;
updateDisplay();
renderHistory();
Storage.save();
});
elements.darkModeToggle.addEventListener('change', toggleTheme);
elements.tabBtns.forEach(btn => btn.addEventListener('click', handleTabClick));
// Keyboard shortcuts
document.addEventListener('keydown', e => {
if (e.ctrlKey || e.metaKey) {
switch (e.key) {
case 'e':
e.preventDefault();
exportHistory();
break;
case 'x':
e.preventDefault();
gradeHistory = [];
renderHistory();
updateStatistics();
break;
case 'd':
e.preventDefault();
toggleTheme();
break;
}
}
});
}
function initializeApp() {
Storage.load();
updateSettingsUI();
renderHistory();
updateStatistics();
setupEventListeners();
elements.tabBtns[0].classList.add('tab-active');
elements.tabPanels[0].classList.remove('hidden');
}
initializeApp();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment