feat: add METAR quiz tool for aviation weather practice

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Bastian de Byl
2026-01-09 14:02:39 -05:00
parent b8fb241e84
commit 3cfa429d12
4 changed files with 668 additions and 0 deletions

View File

@@ -0,0 +1,627 @@
{{- define "main" }}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "METAR Quiz",
"description": "{{ .Description }}",
"url": "{{ .Permalink }}",
"applicationCategory": "EducationalApplication",
"operatingSystem": "Any",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"author": {
"@type": "Person",
"name": "{{ .Site.Params.author }}"
}
}
</script>
<style>
.quiz-container {
max-width: 700px;
margin: 0 auto;
padding: 1rem;
}
.quiz-header {
text-align: center;
margin-bottom: 2rem;
}
.quiz-controls {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 1.5rem;
}
.quiz-controls select {
padding: 0.5rem 1rem;
font-size: 1rem;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--code-bg);
color: var(--primary);
cursor: pointer;
}
.quiz-controls select:focus {
outline: none;
border-color: var(--primary);
}
.start-btn, .submit-btn, .next-btn, .retake-btn, .stop-btn {
padding: 0.75rem 2rem;
font-size: 1.1rem;
border: none;
border-radius: 4px;
cursor: pointer;
background: var(--primary);
color: var(--theme);
font-weight: 600;
transition: opacity 0.2s;
}
.start-btn:hover, .submit-btn:hover, .next-btn:hover, .retake-btn:hover, .stop-btn:hover {
opacity: 0.85;
}
.stop-btn {
background: #666;
}
.quiz-area {
display: none;
text-align: center;
}
.quiz-area.active {
display: block;
}
.question-prompt {
font-size: 1.1rem;
color: var(--secondary);
margin-bottom: 0.5rem;
}
.question-text {
font-size: 1.8rem;
font-weight: 700;
margin-bottom: 1.5rem;
color: var(--primary);
padding: 1rem;
background: var(--code-bg);
border-radius: 8px;
min-height: 3rem;
}
.answer-input {
padding: 0.75rem 1rem;
font-size: 1.2rem;
border: 2px solid var(--border);
border-radius: 4px;
background: var(--code-bg);
color: var(--primary);
width: 200px;
text-align: center;
text-transform: uppercase;
}
.answer-input:focus {
outline: none;
border-color: var(--primary);
}
.input-row {
display: flex;
gap: 1rem;
justify-content: center;
align-items: center;
margin-bottom: 1.5rem;
}
.progress-text {
font-size: 1rem;
color: var(--secondary);
margin-top: 1rem;
}
.feedback {
font-size: 1.2rem;
font-weight: 600;
margin: 1rem 0;
padding: 1rem;
border-radius: 4px;
display: none;
}
.feedback.correct {
display: block;
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
.feedback.incorrect {
display: block;
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
.results-area {
display: none;
text-align: center;
}
.results-area.active {
display: block;
}
.score-display {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 1rem;
color: var(--primary);
}
.score-subtitle {
font-size: 1.2rem;
color: var(--secondary);
margin-bottom: 2rem;
}
.results-breakdown {
text-align: left;
max-height: 400px;
overflow-y: auto;
margin-bottom: 1.5rem;
border: 1px solid var(--border);
border-radius: 8px;
}
.result-item {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.result-item:last-child {
border-bottom: none;
}
.result-item.correct {
background: rgba(34, 197, 94, 0.08);
}
.result-item.incorrect {
background: rgba(239, 68, 68, 0.08);
}
.result-question {
flex: 1;
}
.result-answers {
font-family: monospace;
font-size: 0.95rem;
}
.result-your-answer {
color: var(--secondary);
}
.result-correct-answer {
color: #22c55e;
font-weight: 600;
}
.result-icon {
font-size: 1.2rem;
}
.shuffle-stats {
font-size: 1.1rem;
color: var(--secondary);
margin-bottom: 1rem;
}
.shuffle-stats .correct-count {
color: #22c55e;
font-weight: 600;
}
.shuffle-stats .incorrect-count {
color: #ef4444;
font-weight: 600;
}
.setup-area {
text-align: center;
}
.button-row {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 1rem;
}
</style>
<article class="quiz-container">
<header class="quiz-header">
<h1>METAR Quiz</h1>
<p class="post-description">Practice aviation weather abbreviations for METAR and TAF reports</p>
</header>
<div id="setup" class="setup-area">
<div class="quiz-controls">
<select id="modeSelect">
<option value="quiz">Quiz Mode</option>
<option value="shuffle">Shuffle Mode</option>
</select>
<select id="difficultySelect">
<option value="normal">Weather Only</option>
<option value="taf">Weather + TAF</option>
<option value="hard">All (Hard)</option>
</select>
</div>
<button class="start-btn" onclick="startQuiz()">Start</button>
</div>
<div id="quizArea" class="quiz-area">
<p class="question-prompt">What is the abbreviation for:</p>
<div id="questionText" class="question-text"></div>
<div class="input-row">
<input type="text" id="answerInput" class="answer-input" placeholder="Enter code" autocomplete="off" maxlength="10">
<button class="submit-btn" onclick="submitAnswer()">Submit</button>
</div>
<div id="feedback" class="feedback"></div>
<p id="progressText" class="progress-text"></p>
<div id="shuffleStats" class="shuffle-stats" style="display: none;">
Correct: <span class="correct-count" id="correctCount">0</span> |
Wrong: <span class="incorrect-count" id="wrongCount">0</span>
</div>
<div class="button-row" id="stopRow" style="display: none;">
<button class="stop-btn" onclick="stopShuffle()">Stop</button>
</div>
</div>
<div id="resultsArea" class="results-area">
<div id="scoreDisplay" class="score-display"></div>
<p id="scoreSubtitle" class="score-subtitle"></p>
<div id="resultsBreakdown" class="results-breakdown"></div>
<button class="retake-btn" onclick="retakeQuiz()">Retake Quiz</button>
</div>
</article>
<script>
(function() {
// METAR Data
const PRECIPITATION = [
{ abbr: "RA", desc: "Rain" },
{ abbr: "SN", desc: "Snow" },
{ abbr: "DZ", desc: "Drizzle" },
{ abbr: "GR", desc: "Hail" },
{ abbr: "GS", desc: "Small Hail/Snow Pellets" },
{ abbr: "PL", desc: "Ice Pellets" },
{ abbr: "SG", desc: "Snow Grains" },
{ abbr: "IC", desc: "Ice Crystals" },
{ abbr: "UP", desc: "Unknown Precipitation" }
];
const DESCRIPTORS = [
{ abbr: "TS", desc: "Thunderstorm" },
{ abbr: "SH", desc: "Showers" },
{ abbr: "FZ", desc: "Freezing" },
{ abbr: "BL", desc: "Blowing" },
{ abbr: "DR", desc: "Drifting" },
{ abbr: "MI", desc: "Shallow" },
{ abbr: "BC", desc: "Patches" },
{ abbr: "PR", desc: "Partial" }
];
const OBSCURATIONS = [
{ abbr: "FG", desc: "Fog" },
{ abbr: "BR", desc: "Mist" },
{ abbr: "HZ", desc: "Haze" },
{ abbr: "FU", desc: "Smoke" },
{ abbr: "DU", desc: "Widespread Dust" },
{ abbr: "SA", desc: "Sand" },
{ abbr: "VA", desc: "Volcanic Ash" },
{ abbr: "PY", desc: "Spray" }
];
const SKY_CONDITIONS = [
{ abbr: "SKC", desc: "Sky Clear" },
{ abbr: "CLR", desc: "Clear (automated)" },
{ abbr: "FEW", desc: "Few (1-2 oktas)" },
{ abbr: "SCT", desc: "Scattered (3-4 oktas)" },
{ abbr: "BKN", desc: "Broken (5-7 oktas)" },
{ abbr: "OVC", desc: "Overcast (8 oktas)" },
{ abbr: "VV", desc: "Vertical Visibility" },
{ abbr: "CB", desc: "Cumulonimbus" },
{ abbr: "TCU", desc: "Towering Cumulus" }
];
const OTHER_WEATHER = [
{ abbr: "VC", desc: "Vicinity" },
{ abbr: "FC", desc: "Funnel Cloud" },
{ abbr: "+FC", desc: "Tornado/Waterspout" },
{ abbr: "PO", desc: "Dust/Sand Whirls" },
{ abbr: "SQ", desc: "Squall" },
{ abbr: "SS", desc: "Sandstorm" },
{ abbr: "DS", desc: "Dust Storm" }
];
const TAF_CODES = [
{ abbr: "BECMG", desc: "Becoming" },
{ abbr: "TEMPO", desc: "Temporary" },
{ abbr: "PROB30", desc: "30% Probability" },
{ abbr: "PROB40", desc: "40% Probability" },
{ abbr: "FM", desc: "From (time)" },
{ abbr: "NSW", desc: "No Significant Weather" },
{ abbr: "CAVOK", desc: "Ceiling and Visibility OK" }
];
const NON_WEATHER = [
{ abbr: "FMH-1", desc: "Federal Meteorological Handbook No. 1" },
{ abbr: "FMH2", desc: "Federal Meteorological Handbook No. 2" },
{ abbr: "FIBI", desc: "Filed But Impracticable to Transmit" },
{ abbr: "METAR", desc: "Meteorological Aerodrome Report" },
{ abbr: "SPECI", desc: "Special (unscheduled observation)" },
{ abbr: "AUTO", desc: "Automated observation" },
{ abbr: "COR", desc: "Correction" },
{ abbr: "RMK", desc: "Remarks" },
{ abbr: "AO1", desc: "Automated without precipitation sensor" },
{ abbr: "AO2", desc: "Automated with precipitation sensor" },
{ abbr: "SLP", desc: "Sea Level Pressure" },
{ abbr: "NOSIG", desc: "No Significant Change" }
];
// State
let mode = 'quiz';
let difficulty = 'normal';
let questions = [];
let currentIndex = 0;
let answers = [];
let correctCount = 0;
let incorrectCount = 0;
let waitingForNext = false;
// DOM Elements
const setup = document.getElementById('setup');
const quizArea = document.getElementById('quizArea');
const resultsArea = document.getElementById('resultsArea');
const questionText = document.getElementById('questionText');
const answerInput = document.getElementById('answerInput');
const feedback = document.getElementById('feedback');
const progressText = document.getElementById('progressText');
const shuffleStats = document.getElementById('shuffleStats');
const stopRow = document.getElementById('stopRow');
const correctCountEl = document.getElementById('correctCount');
const wrongCountEl = document.getElementById('wrongCount');
const scoreDisplay = document.getElementById('scoreDisplay');
const scoreSubtitle = document.getElementById('scoreSubtitle');
const resultsBreakdown = document.getElementById('resultsBreakdown');
const modeSelect = document.getElementById('modeSelect');
const difficultySelect = document.getElementById('difficultySelect');
// Fisher-Yates shuffle
function shuffle(array) {
const arr = [...array];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
// Build questions with intensity variants for precipitation
function buildQuestions() {
let items = [];
// Add precipitation with intensity variants
PRECIPITATION.forEach(p => {
// Random intensity: 0 = light, 1 = moderate, 2 = heavy
const intensity = Math.floor(Math.random() * 3);
if (intensity === 0) {
items.push({ abbr: "-" + p.abbr, desc: "Light " + p.desc });
} else if (intensity === 2) {
items.push({ abbr: "+" + p.abbr, desc: "Heavy " + p.desc });
} else {
items.push(p);
}
});
// Add other weather categories (actual weather phenomena)
items = items.concat(DESCRIPTORS, OBSCURATIONS, SKY_CONDITIONS, OTHER_WEATHER);
// Add TAF format codes for taf and hard modes
if (difficulty === 'taf' || difficulty === 'hard') {
items = items.concat(TAF_CODES);
}
// Add non-weather codes for hard mode only
if (difficulty === 'hard') {
items = items.concat(NON_WEATHER);
}
return shuffle(items);
}
// Start quiz
window.startQuiz = function() {
mode = modeSelect.value;
difficulty = difficultySelect.value;
questions = buildQuestions();
currentIndex = 0;
answers = [];
correctCount = 0;
incorrectCount = 0;
waitingForNext = false;
setup.style.display = 'none';
resultsArea.classList.remove('active');
quizArea.classList.add('active');
if (mode === 'shuffle') {
shuffleStats.style.display = 'block';
stopRow.style.display = 'flex';
progressText.style.display = 'none';
} else {
shuffleStats.style.display = 'none';
stopRow.style.display = 'none';
progressText.style.display = 'block';
}
showQuestion();
};
// Show current question
function showQuestion() {
if (mode === 'quiz' && currentIndex >= questions.length) {
showResults();
return;
}
// For shuffle mode, loop back
if (mode === 'shuffle' && currentIndex >= questions.length) {
questions = buildQuestions();
currentIndex = 0;
}
const q = questions[currentIndex];
questionText.textContent = q.desc;
answerInput.value = '';
answerInput.focus();
feedback.className = 'feedback';
feedback.style.display = 'none';
waitingForNext = false;
if (mode === 'quiz') {
progressText.textContent = `Question ${currentIndex + 1} of ${questions.length}`;
}
correctCountEl.textContent = correctCount;
wrongCountEl.textContent = incorrectCount;
}
// Submit answer
window.submitAnswer = function() {
if (waitingForNext) return;
const userAnswer = answerInput.value.trim().toUpperCase();
if (!userAnswer) return;
// Prevent double-submit in shuffle mode
if (mode === 'shuffle') {
waitingForNext = true;
}
const q = questions[currentIndex];
const correctAnswer = q.abbr.toUpperCase();
const isCorrect = userAnswer === correctAnswer;
if (mode === 'quiz') {
answers.push({
question: q.desc,
userAnswer: userAnswer,
correctAnswer: q.abbr,
isCorrect: isCorrect
});
currentIndex++;
if (currentIndex >= questions.length) {
showResults();
} else {
showQuestion();
}
} else {
// Shuffle mode - show feedback and auto-advance
if (isCorrect) {
correctCount++;
feedback.textContent = 'Correct!';
feedback.className = 'feedback correct';
} else {
incorrectCount++;
feedback.textContent = `Incorrect. The answer is: ${q.abbr}`;
feedback.className = 'feedback incorrect';
}
feedback.style.display = 'block';
correctCountEl.textContent = correctCount;
wrongCountEl.textContent = incorrectCount;
// Auto-advance after showing feedback
currentIndex++;
setTimeout(() => {
showQuestion();
}, isCorrect ? 800 : 1500);
}
};
// Stop shuffle
window.stopShuffle = function() {
quizArea.classList.remove('active');
setup.style.display = 'block';
};
// Show results (quiz mode)
function showResults() {
quizArea.classList.remove('active');
resultsArea.classList.add('active');
const correct = answers.filter(a => a.isCorrect).length;
const total = answers.length;
const percentage = Math.round((correct / total) * 100);
scoreDisplay.textContent = `${percentage}%`;
scoreSubtitle.textContent = `${correct} out of ${total} correct`;
let html = '';
answers.forEach(a => {
const statusClass = a.isCorrect ? 'correct' : 'incorrect';
const icon = a.isCorrect ? '&#10003;' : '&#10007;';
html += `
<div class="result-item ${statusClass}">
<span class="result-question">${a.question}</span>
<span class="result-answers">
<span class="result-your-answer">${a.userAnswer}</span>
${!a.isCorrect ? ` → <span class="result-correct-answer">${a.correctAnswer}</span>` : ''}
</span>
<span class="result-icon">${icon}</span>
</div>
`;
});
resultsBreakdown.innerHTML = html;
}
// Retake quiz
window.retakeQuiz = function() {
resultsArea.classList.remove('active');
setup.style.display = 'block';
};
// Enter key to submit
answerInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
submitAnswer();
}
});
})();
</script>
{{- end }}{{/* end main */}}