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:
1 parent
006c865b87
commit
7f6268d6e5
6 files changed
+115
-7
No files matched your search
@@ -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
@@ -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
@@ -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 = () => {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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(`
|
||||
|
||||
Reference in new issue
Block a user