Add word list preview, young learner mode, and practice improvements

- Show full word list before practice starts so students can study words first
- Add per-student young learner mode (set in admin): word stays visible while typing
- Center all practice card content (speak button, prompt, feedback, check button)
- Remove 10-word-per-session cap so large lists (25-30 words) practice all words
- Double the misspelled-word study countdown from 4s to 8s
- Fix word list preview numbering for 10+ items (inline numbers, no clipping)
- Add young_learner column to students table with admin toggle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-05 10:43:24 -04:00
1 parent 006c865b87
commit 7f6268d6e5
6 files changed
+115 -7

No files matched your search

+1
View File
@@ -229,6 +229,7 @@ tryAlter(`ALTER TABLE students ADD COLUMN streak_date TEXT`);
tryAlter(`ALTER TABLE students ADD COLUMN streak_count INTEGER NOT NULL DEFAULT 0`);
tryAlter(`ALTER TABLE students ADD COLUMN best_streak INTEGER NOT NULL DEFAULT 0`);
tryAlter(`ALTER TABLE students ADD COLUMN bg_pattern TEXT NOT NULL DEFAULT 'none'`);
tryAlter(`ALTER TABLE students ADD COLUMN young_learner INTEGER NOT NULL DEFAULT 0`);
tryAlter(`ALTER TABLE lists ADD COLUMN group_name TEXT NOT NULL DEFAULT ''`);
tryAlter(`ALTER TABLE decks ADD COLUMN group_name TEXT NOT NULL DEFAULT ''`);
+17 -1
View File
@@ -128,11 +128,18 @@ async function loadKids() {
.map(([t, c]) => `<button class="theme-swatch theme-${t} ${t === theme ? 'selected' : ''}" data-student-theme="${s.id}" data-theme="${t}" title="${t}" style="background:${c}"></button>`)
.join('');
const streak = s.streak_count >= 2 ? `<span style="color:#e8802a;font-size:.85rem">🔥 ${s.streak_count} day streak</span>` : '';
return `<div class="item-row">
const youngLabel = s.young_learner
? `<span class="badge-young" title="Young learner mode on">👶 Young</span>`
: '';
return `<div class="item-row" style="flex-wrap:wrap;gap:0.5rem">
<span style="font-size:1.5rem">${esc(s.emoji)}</span>
<strong class="grow">${esc(s.name)}</strong>
${streak}
${youngLabel}
<div class="theme-swatch-row" title="Kid's color theme">${swatches}</div>
<label class="young-learner-toggle" title="Show word while typing (for younger kids)" style="font-size:0.85rem;display:flex;align-items:center;gap:0.3rem;cursor:pointer">
<input type="checkbox" data-young-learner="${s.id}" ${s.young_learner ? 'checked' : ''}> 👶 See it first
</label>
<button class="danger" data-del-student="${s.id}">Remove</button>
</div>`;
})
@@ -151,6 +158,15 @@ async function loadKids() {
})
);
document.querySelectorAll('[data-young-learner]').forEach((chk) =>
chk.addEventListener('change', async () => {
await api(`/api/students/${chk.dataset.youngLearner}/young-learner`, {
method: 'PATCH',
body: { young_learner: chk.checked },
});
})
);
document.querySelectorAll('[data-del-student]').forEach((btn) =>
btn.addEventListener('click', async () => {
if (!confirm('Remove this kid and all their progress? This cannot be undone.')) return;
+46 -2
View File
@@ -316,6 +316,7 @@ async function openKid(id, tab = 'today', welcome = false) {
api(`/api/students/${id}/tests`),
]);
currentStudent = state.student;
youngLearnerMode = !!currentStudent.young_learner;
applyTheme(currentStudent.theme || 'blue');
applyBgPattern(currentStudent.bg_pattern || 'none');
updateThemeDots(currentStudent.theme || 'blue');
@@ -1959,7 +1960,35 @@ async function finishFlashcards(nothingDue) {
const practice = { words: [], i: 0, missed: [], missedWords: [], firstTryCorrect: 0, awaitingRetype: false, itemId: null, streak: 0 };
// Young learner mode: word stays visible while typing (set per-student by parent in admin)
let youngLearnerMode = false;
// Pending practice args stored while the word-list preview is showing
let pendingPractice = null;
async function startPractice({ listId, itemId } = {}) {
const previewListId = listId || currentSpellingListId;
if (previewListId) {
pendingPractice = { listId, itemId };
const list = await api(`/api/lists/${previewListId}`);
$('#word-preview-title').textContent = list.name;
$('#word-preview-list').innerHTML = list.words
.map((w, i) => `<li><span class="word-num">${i + 1}.</span><span>${esc(w.word)}${w.sentence ? `<span class="word-sentence">${esc(w.sentence)}</span>` : ''}</span></li>`)
.join('');
show('word-preview');
return;
}
await startPracticeNow({ listId, itemId });
}
$('#word-preview-start').addEventListener('click', () => {
if (!pendingPractice) return;
const args = pendingPractice;
pendingPractice = null;
startPracticeNow(args);
});
async function startPracticeNow({ listId, itemId } = {}) {
const url = listId ? `/api/session/${currentStudent.id}?listId=${listId}` : `/api/session/${currentStudent.id}`;
const { words } = await api(url);
if (words.length === 0) {
@@ -1982,10 +2011,25 @@ function presentPracticeWord() {
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 = '';
if (youngLearnerMode) {
showWordFirst(w);
} else {
$('#practice-prompt').textContent = 'Listen, then type the word:';
input.disabled = false;
input.focus();
speakWord(w);
}
}
function showWordFirst(w) {
const input = $('#practice-input');
$('#practice-prompt').textContent = 'Look at the word and type it:';
$('#feedback').innerHTML = `<div class="study-word">${esc(w.word)}</div>`;
input.value = '';
input.disabled = false;
input.focus();
speakWord(w);
@@ -2043,7 +2087,7 @@ function showStudyThenRetype(typed, w) {
input.disabled = true;
const d = diffLetters(typed, w.word);
const fb = $('#feedback');
let secs = 4;
let secs = 8;
const hint = `Hint: starts with <strong>${esc(w.word[0].toUpperCase())}</strong>`;
const render = () => {
+11
View File
@@ -195,6 +195,17 @@
<div id="flash-done" class="done-card" hidden></div>
</section>
<!-- Word list preview (shown before practice starts) -->
<section id="view-word-preview" class="view" hidden>
<button class="back-btn" data-back="word-preview">← Back</button>
<div class="word-card">
<h2 id="word-preview-title" class="preview-title"></h2>
<p class="prompt">Here are your spelling words:</p>
<ol id="word-preview-list" class="word-preview-list"></ol>
<button id="word-preview-start" class="check-btn">Ready! Start Practice →</button>
</div>
</section>
<!-- Practice (spelling) -->
<section id="view-practice" class="view" hidden>
<button class="back-btn" data-back="practice">← Stop</button>
+32 -1
View File
@@ -35,13 +35,16 @@ body {
#view-assignment,
#view-quiz,
#view-flashcards,
#view-practice,
#view-test,
#view-worksheet {
max-width: 960px;
text-align: left;
}
#view-practice {
max-width: 960px;
}
#view-crossword {
max-width: 100%;
text-align: left;
@@ -279,6 +282,34 @@ body:has(#view-home:not([hidden])) .page-confetti { display: block; }
.countdown { color: var(--muted); }
.hint-badge { font-size: 1rem; color: var(--accent-dark); margin: 0.3rem 0 0.5rem; }
/* Word list preview */
.preview-title { font-size: 1.5rem; margin: 0 0 0.25rem; }
.word-preview-list {
list-style: none;
padding: 0;
margin: 0.75rem 0 1.5rem;
text-align: left;
max-height: 55vh;
overflow-y: auto;
}
.word-preview-list li { margin: 0.45rem 0; font-size: 1.2rem; font-weight: 700; display: flex; gap: 0.4rem; }
.word-num { color: var(--muted); min-width: 2rem; text-align: right; flex-shrink: 0; font-weight: 400; }
.word-sentence { display: block; font-size: 0.88rem; font-weight: normal; color: var(--muted); }
/* Young learner toggle */
.young-toggle-row { margin-top: 0.9rem; text-align: center; }
.young-toggle {
cursor: pointer;
font-size: 0.95rem;
display: inline-flex;
align-items: center;
gap: 0.45rem;
color: var(--muted);
user-select: none;
}
.young-toggle input { accent-color: var(--accent); width: 1.1rem; height: 1.1rem; cursor: pointer; }
.young-countdown { font-size: 1.1rem; margin-top: 0.25rem; color: var(--accent-dark); }
.streak-badge {
display: inline-block; margin-left: 0.5rem;
background: #fff3cd; color: #7c5200;
+8 -3
View File
@@ -325,7 +325,7 @@ const GRADABLE_TYPES = ['assignment', 'quiz', 'matching', 'crossword', 'spelling
// ---------- kid picker ----------
app.get('/api/students', (req, res) => {
res.json(db.prepare(`SELECT id, name, emoji, theme, bg_pattern, streak_count, streak_date FROM students ORDER BY name`).all());
res.json(db.prepare(`SELECT id, name, emoji, theme, bg_pattern, streak_count, streak_date, young_learner FROM students ORDER BY name`).all());
});
const VALID_THEMES = ['blue','green','purple','orange','pink','red','teal','yellow','indigo'];
@@ -343,6 +343,12 @@ app.patch('/api/students/:id/bg-pattern', (req, res) => {
res.json({ ok: true });
});
app.patch('/api/students/:id/young-learner', requirePin, (req, res) => {
const val = req.body.young_learner ? 1 : 0;
db.prepare(`UPDATE students SET young_learner = ? WHERE id = ?`).run(val, req.params.id);
res.json({ ok: true });
});
app.patch('/api/students/:id/emoji', (req, res) => {
const emoji = req.body.emoji ? String(req.body.emoji).trim() : null;
if (!emoji) return res.status(400).json({ error: 'emoji required' });
@@ -370,7 +376,7 @@ app.post('/api/students/:id/complete-day', (req, res) => {
// Everything the standalone spelling home screen needs
app.get('/api/state/:studentId', (req, res) => {
const id = req.params.studentId;
const student = db.prepare(`SELECT id, name, emoji, theme, bg_pattern FROM students WHERE id = ?`).get(id);
const student = db.prepare(`SELECT id, name, emoji, theme, bg_pattern, young_learner FROM students WHERE id = ?`).get(id);
if (!student) return res.status(404).json({ error: 'No such student' });
const assignment = db.prepare(`
@@ -415,7 +421,6 @@ app.get('/api/session/:studentId', (req, res) => {
LEFT JOIN progress p ON p.word_id = w.id AND p.student_id = ?
WHERE w.list_id = ? AND COALESCE(p.box, 0) < ${MASTERED_BOX}
ORDER BY box, RANDOM()
LIMIT 10
`).all(id, listId);
const reviews = db.prepare(`