diff --git a/db.js b/db.js
index 934914d..543681a 100644
--- a/db.js
+++ b/db.js
@@ -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 ''`);
diff --git a/public/admin.js b/public/admin.js
index f8b4d26..9ec7d46 100644
--- a/public/admin.js
+++ b/public/admin.js
@@ -128,11 +128,18 @@ async function loadKids() {
.map(([t, c]) => ``)
.join('');
const streak = s.streak_count >= 2 ? `🔥 ${s.streak_count} day streak` : '';
- return `
+ const youngLabel = s.young_learner
+ ? `
👶 Young`
+ : '';
+ return `
${esc(s.emoji)}
${esc(s.name)}
${streak}
+ ${youngLabel}
${swatches}
+
`;
})
@@ -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;
diff --git a/public/app.js b/public/app.js
index 3c0f33a..6ea240c 100644
--- a/public/app.js
+++ b/public/app.js
@@ -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) => `
${i + 1}.${esc(w.word)}${w.sentence ? `${esc(w.sentence)}` : ''}`)
+ .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 = `
${esc(w.word)}
`;
+ 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
${esc(w.word[0].toUpperCase())}`;
const render = () => {
diff --git a/public/index.html b/public/index.html
index 38f9128..db1ea32 100644
--- a/public/index.html
+++ b/public/index.html
@@ -195,6 +195,17 @@
+
+
+
+
+
+
Here are your spelling words:
+
+
+
+
+
diff --git a/public/styles.css b/public/styles.css
index 83da4c8..320dee0 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -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;
diff --git a/server.js b/server.js
index 934ffdd..529f9f1 100644
--- a/server.js
+++ b/server.js
@@ -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(`