Add 4 UX improvements to spelling/flashcards panel

- Assign → button on each list row opens a popup to pick a kid directly
  without scrolling up to the assignment dropdowns
- Hide/Unhide button on built-in lists (stored in localStorage) keeps
  UNGROUPED clean; Show hidden toggle reveals them dimmed when needed
- Mastery progress bar on each kid showing X/Y words mastered for their
  current assigned list (server now returns listProgress in /api/overview)
- Filter-lists / filter-decks search input for fast lookup by name

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-07-27 13:47:27 -04:00
1 parent 5c2b5baec2
commit 774979f2e4
4 files changed
+152 -21

No files matched your search

+13
View File
@@ -300,6 +300,10 @@
<h2>This week's list per kid</h2>
<div id="assign-rows"></div>
<h2>Word lists</h2>
<div class="filter-bar">
<input id="list-filter" type="search" placeholder="Filter lists…" aria-label="Filter lists">
<button type="button" id="list-show-hidden" class="secondary small" hidden>Show hidden</button>
</div>
<div id="list-rows"></div>
<h2>Test results</h2>
<div id="results-area"></div>
@@ -347,6 +351,9 @@
<h1>Flashcard Decks</h1>
<div class="panel-cols">
<div class="panel-col">
<div class="filter-bar">
<input id="deck-filter" type="search" placeholder="Filter decks…" aria-label="Filter decks">
</div>
<div id="deck-rows"></div>
</div>
<div class="panel-col">
@@ -437,6 +444,12 @@
</main>
</div>
<!-- Floating assign popup -->
<div id="assign-popup" hidden>
<p class="assign-popup-label">Assign to…</p>
<div id="assign-popup-students"></div>
</div>
<script src="admin.js"></script>
</body>
</html>
+98 -21
View File
@@ -1274,9 +1274,11 @@ async function renderGradebook(courseId) {
// Spelling (word lists + weekly assignment + results)
// ============================================================
let cachedStudents = [];
async function loadSpelling() {
const [overview, lists] = await Promise.all([api('/api/overview'), api('/api/lists')]);
cachedLists = lists;
cachedStudents = overview.students;
renderAssignRows(overview.students);
renderLists(lists);
renderResults(overview.students);
@@ -1287,16 +1289,20 @@ function renderAssignRows(students) {
`<option value="">— no list —</option>` +
cachedLists.map((l) => `<option value="${l.id}" ${sel === l.id ? 'selected' : ''}>${esc(l.name)}</option>`).join('');
$('#assign-rows').innerHTML = students.length
? students
.map(
(s) => `<div class="item-row">
<span>${esc(s.emoji)}</span>
<strong class="grow">${esc(s.name)}</strong>
<span>🌟 ${s.mastered} mastered</span>
<label>This week: <select data-assign="${s.id}">${options(s.assignment?.id)}</select></label>
</div>`
)
.join('')
? students.map((s) => {
const lp = s.listProgress;
const progressHtml = lp
? `<span class="mastery-stat">${lp.mastered}/${lp.total} mastered
<span class="mastery-bar"><span class="mastery-fill" style="width:${lp.total ? Math.round((lp.mastered/lp.total)*100) : 0}%"></span></span>
</span>`
: `<span class="mastery-stat">No list assigned</span>`;
return `<div class="item-row">
<span>${esc(s.emoji)}</span>
<strong class="grow">${esc(s.name)}</strong>
${progressHtml}
<label>This week: <select data-assign="${s.id}">${options(s.assignment?.id)}</select></label>
</div>`;
}).join('')
: `<p class="hint">No kids yet.</p>`;
document.querySelectorAll('[data-assign]').forEach((sel) =>
@@ -1334,28 +1340,51 @@ function renderGroupSections(groups, renderItem) {
return html || '<p class="hint">None yet.</p>';
}
function renderLists(lists) {
const groups = groupByName(lists);
const hiddenLists = new Set(JSON.parse(localStorage.getItem('hiddenLists') || '[]'));
function saveHiddenLists() { localStorage.setItem('hiddenLists', JSON.stringify([...hiddenLists])); }
function renderLists(lists, filterText = '') {
const q = filterText.toLowerCase();
const visible = lists.filter((l) => !hiddenLists.has(l.id) && (!q || l.name.toLowerCase().includes(q)));
const hidden = lists.filter((l) => hiddenLists.has(l.id) && (!q || l.name.toLowerCase().includes(q)));
const showHiddenBtn = $('#list-show-hidden');
showHiddenBtn.hidden = hidden.length === 0;
showHiddenBtn.textContent = showHiddenBtn.dataset.showing === '1'
? `Hide hidden (${hidden.length})`
: `Show hidden (${hidden.length})`;
const displayList = showHiddenBtn.dataset.showing === '1' ? [...visible, ...hidden] : visible;
const groups = groupByName(displayList);
const groupNames = Object.keys(groups).filter((k) => k).sort();
$('#list-groups-datalist').innerHTML = groupNames.map((g) => `<option value="${esc(g)}">`).join('');
const renderItem = (l) => `<div class="item-row">
<strong class="grow">${esc(l.name)}</strong>
<span>${l.wordCount} words${l.builtin ? ' · built-in' : ''}</span>
<button class="secondary small" data-print-list="${l.id}">🖨 Worksheet</button>
<button data-edit-list="${l.id}">${l.builtin ? 'Copy & edit' : 'Edit'}</button>
<button class="danger" data-del-list="${l.id}">Delete</button>
</div>`;
const renderItem = (l) => {
const isHidden = hiddenLists.has(l.id);
return `<div class="item-row${isHidden ? ' list-hidden-item' : ''}">
<strong class="grow">${esc(l.name)}</strong>
<span class="muted-label">${l.wordCount} words${l.builtin ? ' · built-in' : ''}</span>
<button class="secondary small" data-assign-list="${l.id}">Assign →</button>
${l.builtin ? '' : `<button class="secondary small" data-print-list="${l.id}">🖨 Worksheet</button>`}
<button class="small" data-edit-list="${l.id}">${l.builtin ? 'Copy & edit' : 'Edit'}</button>
${l.builtin
? `<button class="secondary small" data-hide-list="${l.id}">${isHidden ? 'Unhide' : 'Hide'}</button>`
: `<button class="danger small" data-del-list="${l.id}">Delete</button>`}
</div>`;
};
$('#list-rows').innerHTML = renderGroupSections(groups, renderItem);
document.querySelectorAll('[data-assign-list]').forEach((btn) =>
btn.addEventListener('click', (e) => openAssignPopup(e, Number(btn.dataset.assignList)))
);
document.querySelectorAll('[data-print-list]').forEach((btn) =>
btn.addEventListener('click', async () => {
const list = await api(`/api/lists/${btn.dataset.printList}`);
printSpellingWorksheet(list.name, list.words);
})
);
document.querySelectorAll('[data-edit-list]').forEach((btn) =>
btn.addEventListener('click', async () => {
const list = await api(`/api/lists/${btn.dataset.editList}`);
@@ -1379,8 +1408,48 @@ function renderLists(lists) {
loadSpelling();
})
);
document.querySelectorAll('[data-hide-list]').forEach((btn) =>
btn.addEventListener('click', () => {
const id = Number(btn.dataset.hideList);
if (hiddenLists.has(id)) hiddenLists.delete(id); else hiddenLists.add(id);
saveHiddenLists();
renderLists(cachedLists, $('#list-filter').value);
})
);
}
// Filter input
$('#list-filter').addEventListener('input', () => renderLists(cachedLists, $('#list-filter').value));
$('#list-show-hidden').addEventListener('click', () => {
const btn = $('#list-show-hidden');
btn.dataset.showing = btn.dataset.showing === '1' ? '0' : '1';
renderLists(cachedLists, $('#list-filter').value);
});
// ---- Assign popup ----
function openAssignPopup(e, listId) {
const popup = $('#assign-popup');
$('#assign-popup-students').innerHTML = cachedStudents.map((s) =>
`<button class="assign-popup-student" data-sid="${s.id}" data-lid="${listId}">${esc(s.emoji)} ${esc(s.name)}</button>`
).join('');
document.querySelectorAll('.assign-popup-student').forEach((btn) =>
btn.addEventListener('click', async () => {
await api('/api/assign', { method: 'POST', body: { studentId: Number(btn.dataset.sid), listId: Number(btn.dataset.lid) } });
popup.hidden = true;
msg(`Assigned!`);
loadSpelling();
})
);
const rect = e.target.getBoundingClientRect();
popup.style.top = `${rect.bottom + window.scrollY + 4}px`;
popup.style.left = `${rect.left + window.scrollX}px`;
popup.hidden = false;
}
document.addEventListener('click', (e) => {
if (!$('#assign-popup').hidden && !e.target.closest('#assign-popup') && !e.target.dataset.assignList)
$('#assign-popup').hidden = true;
});
$('#list-form').addEventListener('submit', async (e) => {
e.preventDefault();
const name = $('#list-name').value.trim();
@@ -1563,7 +1632,13 @@ async function printSpellingReport(testId) {
async function loadDecks() {
const decks = await api('/api/decks');
cachedDecks = decks;
const groups = groupByName(decks);
renderDecks(decks, $('#deck-filter').value);
}
function renderDecks(decks, filterText = '') {
const q = filterText.toLowerCase();
const visible = decks.filter((d) => !q || d.name.toLowerCase().includes(q));
const groups = groupByName(visible);
const groupNames = Object.keys(groups).filter((k) => k).sort();
$('#deck-groups-datalist').innerHTML = groupNames.map((g) => `<option value="${esc(g)}">`).join('');
@@ -1597,6 +1672,8 @@ async function loadDecks() {
);
}
$('#deck-filter').addEventListener('input', () => renderDecks(cachedDecks, $('#deck-filter').value));
$('#deck-form').addEventListener('submit', async (e) => {
e.preventDefault();
const name = $('#deck-name').value.trim();
+32
View File
@@ -220,6 +220,7 @@ button.danger { background: var(--bad); }
flex-wrap: wrap;
}
.item-row .grow { flex: 1; min-width: 120px; }
.muted-label { font-size: 0.82rem; color: var(--muted); white-space: nowrap; }
.item-row select { padding: 0.4rem; border-radius: 8px; border: 2px solid #d8d2c0; font-family: inherit; }
.item-row button {
font-size: 0.9rem; font-family: inherit; border: none; border-radius: 8px;
@@ -338,6 +339,37 @@ table.results tr:nth-child(even) td { background: #faf7ee; }
.admin-main h2 { margin: 1.5rem 0 0.5rem; font-size: 1.15rem; }
.panel { text-align: left; }
/* ---------- filter bar ---------- */
.filter-bar { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.5rem; }
.filter-bar input[type="search"] {
flex: 1; font-family: inherit; font-size: 0.95rem;
padding: 0.4rem 0.7rem; border: 2px solid #d8d2c0; border-radius: 10px;
}
/* ---------- mastery progress ---------- */
.mastery-stat { font-size: 0.85rem; color: var(--muted); display: flex; align-items: center; gap: 0.4rem; white-space: nowrap; }
.mastery-bar { display: inline-block; width: 70px; height: 7px; background: #e8e2d0; border-radius: 4px; overflow: hidden; flex-shrink: 0; }
.mastery-fill { display: block; height: 100%; background: var(--accent); border-radius: 4px; transition: width 0.3s; }
/* ---------- assign popup ---------- */
#assign-popup {
position: absolute; z-index: 200;
background: var(--card); border: 2px solid #d8d2c0; border-radius: 12px;
padding: 0.6rem; box-shadow: 0 4px 18px rgba(0,0,0,0.15);
min-width: 160px;
}
.assign-popup-label { font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); margin: 0 0 0.4rem; }
#assign-popup-students { display: flex; flex-direction: column; gap: 0.3rem; }
.assign-popup-student {
font-size: 0.95rem; font-family: inherit; font-weight: 600;
background: #f5f1e8; color: var(--ink); border: none;
border-radius: 8px; padding: 0.45rem 0.8rem; cursor: pointer; text-align: left;
}
.assign-popup-student:hover { background: var(--accent); color: #fff; }
/* ---------- hidden list items ---------- */
.list-hidden-item { opacity: 0.45; }
/* ---------- toggle button rows (new/bulk import) ---------- */
.toggle-btns { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 0.75rem; }
.toggle-btns button.active { background: var(--accent) !important; color: #fff !important; }
+9
View File
@@ -436,6 +436,15 @@ app.get('/api/overview', requirePin, (req, res) => {
s.mastered = db.prepare(
`SELECT COUNT(*) AS n FROM progress WHERE student_id = ? AND box >= ${MASTERED_BOX}`
).get(s.id).n;
if (s.assignment) {
s.listProgress = db.prepare(`
SELECT COUNT(*) AS total,
SUM(CASE WHEN COALESCE(p.box, 0) >= ${MASTERED_BOX} THEN 1 ELSE 0 END) AS mastered
FROM words w
LEFT JOIN progress p ON p.word_id = w.id AND p.student_id = ?
WHERE w.list_id = ?
`).get(s.id, s.assignment.id);
}
s.tests = db.prepare(`
SELECT t.id, t.score, t.total, t.at, l.name AS list
FROM tests t JOIN lists l ON l.id = t.list_id