Add GitHub Actions workflow and update docker-compose to use GHCR
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
23b24b5d21
16 files changed
+4844
No files matched your search
+640
@@ -0,0 +1,640 @@
|
||||
// ---------- tiny helpers ----------
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
const res = await fetch(path, {
|
||||
method: opts.method || 'GET',
|
||||
headers,
|
||||
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Request failed');
|
||||
return data;
|
||||
}
|
||||
|
||||
function show(name) {
|
||||
document.querySelectorAll('.view').forEach((v) => (v.hidden = true));
|
||||
$(`#view-${name}`).hidden = false;
|
||||
}
|
||||
|
||||
const esc = (s) =>
|
||||
String(s).replace(/[&<>"']/g, (c) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])
|
||||
);
|
||||
|
||||
const todayStr = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
const TYPE_ICON = {
|
||||
lesson: '📖', assignment: '📝', quiz: '❓',
|
||||
spelling_practice: '✏️', spelling_test: '⭐', flashcards: '🗂️',
|
||||
};
|
||||
const TYPE_LABEL = {
|
||||
lesson: 'Lesson', assignment: 'Assignment', quiz: 'Quiz',
|
||||
spelling_practice: 'Spelling Practice', spelling_test: 'Spelling Test', flashcards: 'Flashcards',
|
||||
};
|
||||
|
||||
document.querySelectorAll('[data-nav]').forEach((btn) =>
|
||||
btn.addEventListener('click', () => {
|
||||
speechSynthesis.cancel();
|
||||
if (btn.dataset.nav === 'home') {
|
||||
loadHome();
|
||||
show('home');
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Sub-views set this before opening so their back/stop button knows where to return
|
||||
let backTarget = () => openKid(currentStudent.id);
|
||||
document.querySelectorAll('[data-back]').forEach((btn) =>
|
||||
btn.addEventListener('click', () => {
|
||||
speechSynthesis.cancel();
|
||||
backTarget();
|
||||
})
|
||||
);
|
||||
|
||||
// ---------- text to speech (spelling module) ----------
|
||||
|
||||
function speak(text, rate = 0.85) {
|
||||
const u = new SpeechSynthesisUtterance(text);
|
||||
u.rate = rate;
|
||||
u.lang = 'en-US';
|
||||
speechSynthesis.speak(u);
|
||||
}
|
||||
|
||||
function speakWord(w) {
|
||||
speechSynthesis.cancel();
|
||||
speak(w.word);
|
||||
if (w.sentence) speak(w.sentence, 0.95);
|
||||
}
|
||||
|
||||
// ---------- letter diff (LCS alignment, spelling module) ----------
|
||||
|
||||
function diffLetters(typed, correct) {
|
||||
const a = typed.toLowerCase(), b = correct.toLowerCase();
|
||||
const m = a.length, n = b.length;
|
||||
const L = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
||||
for (let i = m - 1; i >= 0; i--)
|
||||
for (let j = n - 1; j >= 0; j--)
|
||||
L[i][j] = a[i] === b[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1]);
|
||||
|
||||
const typedParts = [], correctParts = [];
|
||||
let i = 0, j = 0;
|
||||
while (i < m && j < n) {
|
||||
if (a[i] === b[j]) {
|
||||
typedParts.push(`<span class="ltr-ok">${esc(typed[i])}</span>`);
|
||||
correctParts.push(`<span class="ltr-ok">${esc(correct[j])}</span>`);
|
||||
i++; j++;
|
||||
} else if (L[i + 1][j] >= L[i][j + 1]) {
|
||||
typedParts.push(`<span class="ltr-bad">${esc(typed[i])}</span>`);
|
||||
i++;
|
||||
} else {
|
||||
correctParts.push(`<span class="ltr-fix">${esc(correct[j])}</span>`);
|
||||
j++;
|
||||
}
|
||||
}
|
||||
while (i < m) typedParts.push(`<span class="ltr-bad">${esc(typed[i++])}</span>`);
|
||||
while (j < n) correctParts.push(`<span class="ltr-fix">${esc(correct[j++])}</span>`);
|
||||
return { typedHtml: typedParts.join(''), correctHtml: correctParts.join('') };
|
||||
}
|
||||
|
||||
// ---------- home (kid picker) ----------
|
||||
|
||||
let currentStudent = null;
|
||||
|
||||
async function loadHome() {
|
||||
const students = await api('/api/students');
|
||||
$('#no-kids').hidden = students.length > 0;
|
||||
$('#kid-cards').innerHTML = students
|
||||
.map(
|
||||
(s) => `<button class="kid-card" data-id="${s.id}">
|
||||
<span class="avatar">${esc(s.emoji)}</span>${esc(s.name)}</button>`
|
||||
)
|
||||
.join('');
|
||||
document.querySelectorAll('.kid-card').forEach((card) =>
|
||||
card.addEventListener('click', () => openKid(Number(card.dataset.id)))
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- kid home: Today / Courses / Spelling ----------
|
||||
|
||||
document.querySelectorAll('.nav-pill').forEach((pill) =>
|
||||
pill.addEventListener('click', () => switchTab(pill.dataset.tab))
|
||||
);
|
||||
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.nav-pill').forEach((p) => p.classList.toggle('active', p.dataset.tab === name));
|
||||
document.querySelectorAll('.tab-panel').forEach((panel) => (panel.hidden = panel.id !== `tab-${name}`));
|
||||
}
|
||||
|
||||
async function openKid(id, tab = 'today') {
|
||||
const [state, agenda, courses] = await Promise.all([
|
||||
api(`/api/state/${id}`),
|
||||
api(`/api/schedule/${id}?date=${todayStr()}`),
|
||||
api(`/api/courses/mine/${id}`),
|
||||
]);
|
||||
currentStudent = state.student;
|
||||
$('#kid-greeting').textContent = `${state.student.emoji} Hi, ${state.student.name}!`;
|
||||
|
||||
renderToday(agenda.tasks);
|
||||
renderCourseCards(courses);
|
||||
renderSpellingTab(state);
|
||||
|
||||
show('kid');
|
||||
switchTab(tab);
|
||||
}
|
||||
|
||||
function renderToday(tasks) {
|
||||
$('#today-empty').hidden = tasks.length > 0;
|
||||
$('#today-list').innerHTML = tasks
|
||||
.map((t) => {
|
||||
if (!t.itemId) {
|
||||
return `<button class="today-row offline" data-schedule-id="${t.id}">
|
||||
<span class="row-check">${t.done ? '✅' : '⬜'}</span>
|
||||
<span class="row-title">${esc(t.offlineTitle)}</span>
|
||||
<span class="row-badge">📌 Offline</span>
|
||||
</button>`;
|
||||
}
|
||||
const badge = statusBadge(t.type, t.subStatus, t.score, t.points_possible, t.done);
|
||||
return `<button class="today-row" data-item-id="${t.itemId}" data-item-type="${t.type}">
|
||||
<span class="row-check">${t.done ? '✅' : TYPE_ICON[t.type]}</span>
|
||||
<span class="row-title">${esc(t.itemTitle)}<small>${esc(t.courseName)}</small></span>
|
||||
<span class="row-badge">${badge}</span>
|
||||
</button>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
document.querySelectorAll('.today-row.offline').forEach((row) =>
|
||||
row.addEventListener('click', async () => {
|
||||
const done = row.querySelector('.row-check').textContent.trim() !== '✅';
|
||||
await api(`/api/schedule/${row.dataset.scheduleId}/done`, { method: 'POST', body: { done } });
|
||||
openKid(currentStudent.id, 'today');
|
||||
})
|
||||
);
|
||||
document.querySelectorAll('.today-row:not(.offline)').forEach((row) =>
|
||||
row.addEventListener('click', () => {
|
||||
backTarget = () => openKid(currentStudent.id, 'today');
|
||||
openItem(Number(row.dataset.itemId), row.dataset.itemType);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function statusBadge(type, status, score, pointsPossible, done) {
|
||||
if (status === 'graded') return `🌟 ${score}/${pointsPossible}`;
|
||||
if (status === 'done') return type === 'assignment' ? '⏳ grading' : '✅ done';
|
||||
if (done) return '✅ done';
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderCourseCards(courses) {
|
||||
$('#courses-empty').hidden = courses.length > 0;
|
||||
$('#course-cards').innerHTML = courses
|
||||
.map((c) => {
|
||||
const pct = c.total > 0 ? Math.round((c.done / c.total) * 100) : 0;
|
||||
return `<button class="course-card" style="border-left-color:${esc(c.color)}" data-course-id="${c.id}">
|
||||
<strong>${esc(c.name)}</strong>
|
||||
<span class="course-subject">${esc(c.subject || '')}</span>
|
||||
<div class="progress-track small"><div class="progress-fill" style="width:${pct}%;background:${esc(c.color)}"></div></div>
|
||||
<span class="course-pct">${c.done} / ${c.total} complete</span>
|
||||
</button>`;
|
||||
})
|
||||
.join('');
|
||||
document.querySelectorAll('.course-card').forEach((card) =>
|
||||
card.addEventListener('click', () => openCourse(Number(card.dataset.courseId)))
|
||||
);
|
||||
}
|
||||
|
||||
function renderSpellingTab(state) {
|
||||
let html;
|
||||
if (state.assignment) {
|
||||
const { mastered, total } = state.listProgress;
|
||||
html = `<strong>This week: ${esc(state.assignment.name)}</strong>
|
||||
<div class="mastery-dots">${'🌟'.repeat(mastered)}${'⚪'.repeat(Math.max(0, total - mastered))}</div>
|
||||
<div>${mastered} of ${total} words mastered</div>`;
|
||||
} else {
|
||||
html = `No list assigned yet — ask a parent to pick one!`;
|
||||
}
|
||||
if (state.dueReviews > 0) {
|
||||
html += `<div>🔁 ${state.dueReviews} old ${state.dueReviews === 1 ? 'word' : 'words'} due for review</div>`;
|
||||
}
|
||||
$('#kid-week').innerHTML = html;
|
||||
$('#btn-test').disabled = !state.assignment;
|
||||
}
|
||||
|
||||
// ---------- course detail ----------
|
||||
|
||||
async function openCourse(courseId) {
|
||||
const course = await api(`/api/courses/${courseId}/detail?studentId=${currentStudent.id}`);
|
||||
$('#course-title').textContent = course.name;
|
||||
$('#course-units').innerHTML = course.units
|
||||
.map(
|
||||
(u) => `<div class="unit-block">
|
||||
<h2>${esc(u.name)}</h2>
|
||||
${u.items
|
||||
.map((it) => {
|
||||
const badge = statusBadge(it.type, it.status, it.score, it.points_possible, it.status !== 'not_started');
|
||||
return `<button class="today-row" data-item-id="${it.id}" data-item-type="${it.type}">
|
||||
<span class="row-check">${it.status === 'not_started' ? TYPE_ICON[it.type] : '✅'}</span>
|
||||
<span class="row-title">${esc(it.title)}<small>${TYPE_LABEL[it.type]}</small></span>
|
||||
<span class="row-badge">${badge}</span>
|
||||
</button>`;
|
||||
})
|
||||
.join('')}
|
||||
</div>`
|
||||
)
|
||||
.join('');
|
||||
document.querySelectorAll('#course-units .today-row').forEach((row) =>
|
||||
row.addEventListener('click', () => {
|
||||
backTarget = () => openCourse(courseId);
|
||||
openItem(Number(row.dataset.itemId), row.dataset.itemType);
|
||||
})
|
||||
);
|
||||
show('course');
|
||||
}
|
||||
|
||||
// ---------- item dispatch ----------
|
||||
|
||||
async function openItem(itemId, type) {
|
||||
if (type === 'lesson') return openLesson(itemId);
|
||||
if (type === 'assignment') return openAssignment(itemId);
|
||||
if (type === 'quiz') return openQuiz(itemId);
|
||||
const item = await api(`/api/items/${itemId}?studentId=${currentStudent.id}`);
|
||||
if (type === 'spelling_practice') return startPractice({ listId: item.list.id, itemId });
|
||||
if (type === 'spelling_test') return startTest({ listId: item.list.id, itemId });
|
||||
if (type === 'flashcards') return startFlashcards({ deckId: item.deck.id, deckName: item.deck.name, itemId });
|
||||
}
|
||||
|
||||
// ---------- lesson ----------
|
||||
|
||||
async function openLesson(itemId) {
|
||||
const item = await api(`/api/items/${itemId}?studentId=${currentStudent.id}`);
|
||||
$('#lesson-kicker').textContent = `${item.course_name} · ${item.unit_name}`;
|
||||
$('#lesson-title').textContent = item.title;
|
||||
$('#lesson-body').innerHTML = item.body;
|
||||
show('lesson');
|
||||
|
||||
const btn = $('#lesson-done-btn');
|
||||
btn.textContent = item.submission ? '✅ Read' : 'Done reading ✓';
|
||||
btn.onclick = async () => {
|
||||
await api(`/api/items/${itemId}/complete`, { method: 'POST', body: { studentId: currentStudent.id, date: todayStr() } });
|
||||
backTarget();
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- assignment ----------
|
||||
|
||||
async function openAssignment(itemId) {
|
||||
const item = await api(`/api/items/${itemId}?studentId=${currentStudent.id}`);
|
||||
$('#assignment-kicker').textContent = `${item.course_name} · ${item.unit_name}`;
|
||||
$('#assignment-title').textContent = item.title;
|
||||
$('#assignment-points').textContent = item.points ? `Worth ${item.points} points` : '';
|
||||
$('#assignment-body').innerHTML = esc(item.body).replace(/\n/g, '<br>');
|
||||
|
||||
const btn = $('#assignment-done-btn');
|
||||
const status = $('#assignment-status');
|
||||
if (item.submission && item.submission.status === 'graded') {
|
||||
btn.hidden = true;
|
||||
status.hidden = false;
|
||||
status.className = 'status-banner good';
|
||||
status.textContent = `🌟 Graded: ${item.submission.score} / ${item.submission.points_possible}`;
|
||||
} else if (item.submission && item.submission.status === 'done') {
|
||||
btn.hidden = true;
|
||||
status.hidden = false;
|
||||
status.className = 'status-banner';
|
||||
status.textContent = `⏳ Turned in — waiting for a parent to grade it.`;
|
||||
} else {
|
||||
btn.hidden = false;
|
||||
status.hidden = true;
|
||||
btn.onclick = async () => {
|
||||
await api(`/api/items/${itemId}/complete`, { method: 'POST', body: { studentId: currentStudent.id, date: todayStr() } });
|
||||
openAssignment(itemId);
|
||||
};
|
||||
}
|
||||
show('assignment');
|
||||
}
|
||||
|
||||
// ---------- quiz ----------
|
||||
|
||||
async function openQuiz(itemId) {
|
||||
const item = await api(`/api/items/${itemId}?studentId=${currentStudent.id}`);
|
||||
$('#quiz-kicker').textContent = `${item.course_name} · ${item.unit_name}`;
|
||||
$('#quiz-title').textContent = item.title;
|
||||
const graded = item.submission && item.submission.status === 'graded';
|
||||
const result = $('#quiz-result');
|
||||
|
||||
if (graded) {
|
||||
result.hidden = false;
|
||||
result.className = 'status-banner good';
|
||||
result.textContent = `🌟 Score: ${item.submission.score} / ${item.submission.points_possible}`;
|
||||
$('#quiz-form').innerHTML = item.questions
|
||||
.map((q) => {
|
||||
const correct = normalizeAnswer(q.given) === normalizeAnswer(q.correct_answer);
|
||||
return `<div class="quiz-question review">
|
||||
<p class="q-prompt">${correct ? '✅' : '❌'} ${esc(q.prompt)}</p>
|
||||
<p class="q-your-answer">Your answer: ${esc(q.given || '(blank)')}</p>
|
||||
${correct ? '' : `<p class="q-correct-answer">Correct answer: ${esc(q.correct_answer)}</p>`}
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
} else {
|
||||
result.hidden = true;
|
||||
$('#quiz-form').innerHTML =
|
||||
item.questions
|
||||
.map((q, i) => {
|
||||
if (q.type === 'mc') {
|
||||
return `<div class="quiz-question">
|
||||
<p class="q-prompt">${i + 1}. ${esc(q.prompt)}</p>
|
||||
${q.choices
|
||||
.map(
|
||||
(c) => `<label class="quiz-choice"><input type="radio" name="q${q.id}" value="${esc(c)}"> ${esc(c)}</label>`
|
||||
)
|
||||
.join('')}
|
||||
</div>`;
|
||||
}
|
||||
if (q.type === 'tf') {
|
||||
return `<div class="quiz-question">
|
||||
<p class="q-prompt">${i + 1}. ${esc(q.prompt)}</p>
|
||||
<label class="quiz-choice"><input type="radio" name="q${q.id}" value="true"> True</label>
|
||||
<label class="quiz-choice"><input type="radio" name="q${q.id}" value="false"> False</label>
|
||||
</div>`;
|
||||
}
|
||||
return `<div class="quiz-question">
|
||||
<p class="q-prompt">${i + 1}. ${esc(q.prompt)}</p>
|
||||
<input type="text" class="quiz-short-input" name="q${q.id}" autocomplete="off">
|
||||
</div>`;
|
||||
})
|
||||
.join('') + `<button type="submit" class="check-btn">Submit Quiz</button>`;
|
||||
|
||||
$('#quiz-form').onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const submitBtn = $('#quiz-form button[type=submit]');
|
||||
if (submitBtn.disabled) return;
|
||||
submitBtn.disabled = true;
|
||||
const answers = {};
|
||||
for (const q of item.questions) {
|
||||
const field = $('#quiz-form').elements[`q${q.id}`];
|
||||
answers[q.id] = field ? (field.value !== undefined ? field.value : '') : '';
|
||||
}
|
||||
await api(`/api/items/${itemId}/quiz-submit`, {
|
||||
method: 'POST',
|
||||
body: { studentId: currentStudent.id, answers, date: todayStr() },
|
||||
});
|
||||
openQuiz(itemId);
|
||||
};
|
||||
}
|
||||
show('quiz');
|
||||
}
|
||||
|
||||
const normalizeAnswer = (s) => String(s ?? '').trim().toLowerCase();
|
||||
|
||||
// ---------- flashcards ----------
|
||||
|
||||
const flash = { cards: [], i: 0, itemId: null, reviewed: 0 };
|
||||
|
||||
async function startFlashcards({ deckId, deckName, itemId }) {
|
||||
const { cards } = await api(`/api/flashcards/session/${currentStudent.id}?deckId=${deckId}`);
|
||||
Object.assign(flash, { cards, i: 0, itemId: itemId || null, reviewed: 0 });
|
||||
$('#flash-deck-name').textContent = deckName || '';
|
||||
$('#flash-card').hidden = false;
|
||||
$('#flash-done').hidden = true;
|
||||
show('flashcards');
|
||||
if (cards.length === 0) return finishFlashcards(true);
|
||||
presentFlashcard();
|
||||
}
|
||||
|
||||
function presentFlashcard() {
|
||||
flashGrading = false;
|
||||
const c = flash.cards[flash.i];
|
||||
$('#flash-progress').textContent = `Card ${flash.i + 1} of ${flash.cards.length}`;
|
||||
$('#flash-bar').style.width = `${(flash.i / flash.cards.length) * 100}%`;
|
||||
$('#flash-front').textContent = c.front;
|
||||
$('#flash-front').hidden = false;
|
||||
$('#flash-back').hidden = true;
|
||||
$('#flash-back').textContent = c.back;
|
||||
$('#flash-reveal').hidden = false;
|
||||
$('#flash-grade-buttons').hidden = true;
|
||||
}
|
||||
|
||||
$('#flash-reveal').addEventListener('click', () => {
|
||||
$('#flash-back').hidden = false;
|
||||
$('#flash-reveal').hidden = true;
|
||||
$('#flash-grade-buttons').hidden = false;
|
||||
});
|
||||
|
||||
let flashGrading = false;
|
||||
|
||||
async function gradeFlashcard(gotIt) {
|
||||
if (flashGrading) return; // guard against a rapid double-tap misgrading two cards
|
||||
flashGrading = true;
|
||||
const c = flash.cards[flash.i];
|
||||
await api('/api/flashcards/grade', { method: 'POST', body: { studentId: currentStudent.id, cardId: c.id, gotIt } });
|
||||
flash.reviewed++;
|
||||
flash.i++;
|
||||
if (flash.i < flash.cards.length) return presentFlashcard();
|
||||
finishFlashcards(false);
|
||||
}
|
||||
$('#flash-again').addEventListener('click', () => gradeFlashcard(false));
|
||||
$('#flash-got-it').addEventListener('click', () => gradeFlashcard(true));
|
||||
|
||||
async function finishFlashcards(nothingDue) {
|
||||
$('#flash-bar').style.width = '100%';
|
||||
$('#flash-progress').textContent = 'Done!';
|
||||
$('#flash-card').hidden = true;
|
||||
if (flash.itemId) {
|
||||
await api(`/api/items/${flash.itemId}/complete`, { method: 'POST', body: { studentId: currentStudent.id, date: todayStr() } });
|
||||
}
|
||||
$('#flash-done').innerHTML = nothingDue
|
||||
? `<div>🎉 Nothing due on this deck right now!</div><button id="flash-home">Back</button>`
|
||||
: `<div>🎉 Reviewed ${flash.reviewed} card${flash.reviewed === 1 ? '' : 's'}!</div><button id="flash-home">Back</button>`;
|
||||
$('#flash-done').hidden = false;
|
||||
$('#flash-home').addEventListener('click', () => backTarget());
|
||||
}
|
||||
|
||||
// ---------- spelling: practice ----------
|
||||
|
||||
const practice = { words: [], i: 0, missed: [], firstTryCorrect: 0, awaitingRetype: false, itemId: null };
|
||||
|
||||
async function startPractice({ listId, itemId } = {}) {
|
||||
const url = listId ? `/api/session/${currentStudent.id}?listId=${listId}` : `/api/session/${currentStudent.id}`;
|
||||
const { words } = await api(url);
|
||||
if (words.length === 0) {
|
||||
$('#practice-card').hidden = true;
|
||||
$('#practice-done').hidden = false;
|
||||
$('#practice-done').innerHTML = `<div>🎉 Nothing to practice right now — all caught up!</div><button id="practice-home">Back</button>`;
|
||||
$('#practice-home').addEventListener('click', () => backTarget());
|
||||
show('practice');
|
||||
return;
|
||||
}
|
||||
Object.assign(practice, { words, i: 0, missed: [], firstTryCorrect: 0, awaitingRetype: false, itemId: itemId || null });
|
||||
$('#practice-card').hidden = false;
|
||||
$('#practice-done').hidden = true;
|
||||
show('practice');
|
||||
presentPracticeWord();
|
||||
}
|
||||
|
||||
function presentPracticeWord() {
|
||||
const w = practice.words[practice.i];
|
||||
practice.awaitingRetype = false;
|
||||
$('#practice-progress').textContent = `Word ${practice.i + 1} of ${practice.words.length}`;
|
||||
$('#practice-bar').style.width = `${(practice.i / practice.words.length) * 100}%`;
|
||||
$('#practice-prompt').textContent = 'Listen, then type the word:';
|
||||
$('#feedback').innerHTML = '';
|
||||
const input = $('#practice-input');
|
||||
input.value = '';
|
||||
input.disabled = false;
|
||||
input.focus();
|
||||
speakWord(w);
|
||||
}
|
||||
|
||||
$('#btn-speak').addEventListener('click', () => speakWord(practice.words[practice.i]));
|
||||
|
||||
$('#practice-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const input = $('#practice-input');
|
||||
const typed = input.value.trim();
|
||||
if (!typed || input.disabled) return;
|
||||
const w = practice.words[practice.i];
|
||||
|
||||
const result = await api('/api/attempt', {
|
||||
method: 'POST',
|
||||
body: { studentId: currentStudent.id, wordId: w.id, typed, firstTry: !practice.awaitingRetype },
|
||||
});
|
||||
|
||||
if (result.correct) {
|
||||
if (!practice.awaitingRetype) practice.firstTryCorrect++;
|
||||
$('#feedback').innerHTML = `<div class="banner good">✅ ${practice.awaitingRetype ? 'You got it!' : 'Correct!'}</div>`;
|
||||
input.disabled = true;
|
||||
setTimeout(nextPracticeWord, 900);
|
||||
} else {
|
||||
if (!practice.awaitingRetype) practice.missed.push(w.word);
|
||||
showStudyThenRetype(typed, w);
|
||||
}
|
||||
});
|
||||
|
||||
// look–cover–write–check: show the diff and the correct word, then hide it and retype
|
||||
function showStudyThenRetype(typed, w) {
|
||||
practice.awaitingRetype = true;
|
||||
const input = $('#practice-input');
|
||||
input.disabled = true;
|
||||
const d = diffLetters(typed, w.word);
|
||||
const fb = $('#feedback');
|
||||
let secs = 4;
|
||||
|
||||
const render = () => {
|
||||
fb.innerHTML = `
|
||||
<div class="banner bad">Not quite — study it!</div>
|
||||
<div class="diff-line"><span class="label">You typed</span>${d.typedHtml}</div>
|
||||
<div class="diff-line"><span class="label">Correct</span>${d.correctHtml}</div>
|
||||
<div class="study-word">${esc(w.word)}</div>
|
||||
<div class="countdown">Memorize it… ${secs}</div>`;
|
||||
};
|
||||
render();
|
||||
|
||||
const timer = setInterval(() => {
|
||||
secs--;
|
||||
if (secs > 0) return render();
|
||||
clearInterval(timer);
|
||||
fb.innerHTML = `<div class="banner bad">🙈 Word hidden — now type it from memory!</div>`;
|
||||
$('#practice-prompt').textContent = 'Type it from memory:';
|
||||
input.value = '';
|
||||
input.disabled = false;
|
||||
input.focus();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async function nextPracticeWord() {
|
||||
practice.i++;
|
||||
if (practice.i < practice.words.length) return presentPracticeWord();
|
||||
$('#practice-bar').style.width = '100%';
|
||||
$('#practice-progress').textContent = 'Done!';
|
||||
$('#practice-card').hidden = true;
|
||||
if (practice.itemId) {
|
||||
await api(`/api/items/${practice.itemId}/complete`, { method: 'POST', body: { studentId: currentStudent.id, date: todayStr() } });
|
||||
}
|
||||
const missedHtml = practice.missed.length
|
||||
? `<p>Words to keep working on:</p><ul>${practice.missed.map((m) => `<li>📌 ${esc(m)}</li>`).join('')}</ul>`
|
||||
: `<p>Perfect round — every word right on the first try! 🏆</p>`;
|
||||
$('#practice-done').innerHTML = `
|
||||
<div>🎉 Practice complete!</div>
|
||||
<div class="big-score">${practice.firstTryCorrect} / ${practice.words.length}</div>
|
||||
<p>right on the first try</p>
|
||||
${missedHtml}
|
||||
<button id="practice-home">Back</button>`;
|
||||
$('#practice-done').hidden = false;
|
||||
$('#practice-home').addEventListener('click', () => backTarget());
|
||||
}
|
||||
|
||||
// ---------- spelling: test ----------
|
||||
|
||||
const test = { list: null, words: [], i: 0, answers: [], itemId: null };
|
||||
|
||||
async function startTest({ listId, itemId } = {}) {
|
||||
const url = listId ? `/api/test/${currentStudent.id}?listId=${listId}` : `/api/test/${currentStudent.id}`;
|
||||
const data = await api(url);
|
||||
Object.assign(test, { list: data.list, words: data.words, i: 0, answers: [], itemId: itemId || null });
|
||||
$('#test-card').hidden = false;
|
||||
$('#test-done').hidden = true;
|
||||
show('test');
|
||||
presentTestWord();
|
||||
}
|
||||
|
||||
function presentTestWord() {
|
||||
const w = test.words[test.i];
|
||||
$('#test-progress').textContent = `Word ${test.i + 1} of ${test.words.length}`;
|
||||
$('#test-bar').style.width = `${(test.i / test.words.length) * 100}%`;
|
||||
const input = $('#test-input');
|
||||
input.value = '';
|
||||
input.focus();
|
||||
speakWord(w);
|
||||
}
|
||||
|
||||
$('#btn-test-speak').addEventListener('click', () => speakWord(test.words[test.i]));
|
||||
|
||||
$('#test-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const typed = $('#test-input').value.trim();
|
||||
if (!typed) return;
|
||||
test.answers.push({ wordId: test.words[test.i].id, typed });
|
||||
test.i++;
|
||||
if (test.i < test.words.length) return presentTestWord();
|
||||
await finishTest();
|
||||
});
|
||||
|
||||
async function finishTest() {
|
||||
speechSynthesis.cancel();
|
||||
const result = await api(`/api/test/${currentStudent.id}`, {
|
||||
method: 'POST',
|
||||
body: { listId: test.list.id, answers: test.answers, itemId: test.itemId, date: todayStr() },
|
||||
});
|
||||
$('#test-bar').style.width = '100%';
|
||||
$('#test-progress').textContent = 'Test finished!';
|
||||
$('#test-card').hidden = true;
|
||||
const rows = result.graded
|
||||
.map((g) =>
|
||||
g.correct
|
||||
? `<li>✅ ${esc(g.word)}</li>`
|
||||
: `<li>❌ ${esc(g.word)} <small>(you wrote "${esc(g.typed)}")</small></li>`
|
||||
)
|
||||
.join('');
|
||||
$('#test-done').innerHTML = `
|
||||
<div>⭐ Test complete!</div>
|
||||
<div class="big-score">${result.score} / ${result.total}</div>
|
||||
<ul>${rows}</ul>
|
||||
<button id="test-home">Back</button>`;
|
||||
$('#test-done').hidden = false;
|
||||
$('#test-home').addEventListener('click', () => backTarget());
|
||||
}
|
||||
|
||||
$('#btn-practice').addEventListener('click', () => {
|
||||
backTarget = () => openKid(currentStudent.id, 'spelling');
|
||||
startPractice();
|
||||
});
|
||||
$('#btn-test').addEventListener('click', () => {
|
||||
backTarget = () => openKid(currentStudent.id, 'spelling');
|
||||
startTest();
|
||||
});
|
||||
|
||||
// ---------- boot ----------
|
||||
|
||||
loadHome();
|
||||
Reference in new issue
Block a user