Redesign admin dashboard with sidebar layout, planner overhaul, and readability fixes
Build and Push Docker Image / build-and-push (push) Successful in 41s

- Replace horizontal top-nav with a dark sidebar (grouped: People / Academics / Tools)
- Add Dashboard landing page with kid stat cards, today's schedule, and recent scores
- Add /api/admin/schedule and /api/admin/recent-scores endpoints for dashboard data
- Add Lock button and mobile sidebar toggle in top bar
- Redesign planner toolbar: two-section layout (nav left, actions right)
- Elevate auto-schedule out of hidden accordion into always-visible inline bar
- Richer planner task cards: course color stripe, course name label, completion counter per day
- Dashed "+ Add task" empty state instead of plain dash
- Fix nav-pill CSS conflict: student tabs (light bg) now use dark text; admin sidebar keeps white text
- Add seed.js demo data script for two students (Emma and Liam)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-10-19 13:31:12 -04:00
1 parent ab32ba8634
commit 7775c4fc3a
5 files changed
+1423 -126

No files matched your search

+154 -22
View File
@@ -82,22 +82,25 @@ async function unlock() {
await loadAppSettings();
$('#pin-gate').hidden = true;
$('#console').hidden = false;
showPanel('kids');
showPanel('dashboard');
if ('Notification' in window && Notification.permission === 'default') Notification.requestPermission();
loadGradingBadge();
loadMessagesBadge();
startAdminMsgPoll();
loadDashboard();
}
async function loadGradingBadge() {
try {
const { count } = await api('/api/grading-queue/count');
const pill = document.querySelector('.nav-pill[data-panel="grading"]');
if (!pill) return;
const lbl = pill.querySelector('.nav-label');
if (count > 0) {
pill.textContent = `✅ Grading (${count})`;
if (lbl) lbl.textContent = `Grading (${count})`;
pill.classList.add('grading-pending');
} else {
pill.textContent = '✅ Grading';
if (lbl) lbl.textContent = 'Grading';
pill.classList.remove('grading-pending');
}
} catch {}
@@ -114,11 +117,20 @@ document.querySelectorAll('[data-nav]').forEach((btn) =>
btn.addEventListener('click', () => showPanel(btn.dataset.nav))
);
const PANEL_TITLES = {
dashboard: 'Dashboard', kids: 'Kids', courses: 'Courses', planner: 'Planner',
grading: 'Grading', messages: 'Messages', gradebook: 'Gradebook',
spelling: 'Spelling', decks: 'Flashcards', settings: 'Settings',
'course-detail': 'Course Detail', 'item-editor': 'Item Editor',
};
function showPanel(name) {
document.querySelectorAll('.panel').forEach((p) => (p.hidden = p.id !== `panel-${name}`));
document.querySelectorAll('.admin-nav .nav-pill').forEach((p) => p.classList.toggle('active', p.dataset.panel === name));
const titleEl = document.getElementById('admin-page-title');
if (titleEl) titleEl.textContent = PANEL_TITLES[name] || name;
const loaders = {
kids: loadKids, courses: loadCourses, planner: loadPlannerPanel,
dashboard: loadDashboard, kids: loadKids, courses: loadCourses, planner: loadPlannerPanel,
grading: loadGrading, messages: loadMessages, gradebook: loadGradebookPanel, spelling: loadSpelling, decks: loadDecks,
settings: loadSettings,
};
@@ -126,6 +138,99 @@ function showPanel(name) {
if (name === 'course-detail') openCourseDetail(currentCourseId);
}
// sidebar toggle + lock button
document.getElementById('sidebar-toggle')?.addEventListener('click', () => {
document.querySelector('.admin-sidebar')?.classList.toggle('open');
});
document.getElementById('btn-lock')?.addEventListener('click', () => {
sessionStorage.removeItem('admin_pin');
location.reload();
});
// ============================================================
// Dashboard
// ============================================================
async function loadDashboard() {
const greetEl = document.getElementById('dashboard-greeting');
const kidGrid = document.getElementById('dashboard-kid-cards');
const schedEl = document.getElementById('dashboard-schedule');
const scoresEl = document.getElementById('dashboard-scores');
if (!greetEl) return;
const hr = new Date().getHours();
const greet = hr < 12 ? 'Good morning' : hr < 17 ? 'Good afternoon' : 'Good evening';
const dateStr = new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' });
greetEl.innerHTML = `<h1>${greet}! 👋</h1><p>${dateStr}</p>`;
try {
const [students, scheduleData, recentScores] = await Promise.all([
api('/api/students'),
api(`/api/admin/schedule?date=${new Date().toISOString().slice(0,10)}`).catch(() => ({ rows: [] })),
api('/api/admin/recent-scores').catch(() => ({ scores: [] })),
]);
const THEME_HEX = { blue:'#4f86f7', green:'#2e9e5b', purple:'#7c5cbf', orange:'#e8802a', pink:'#d45d8a', red:'#e84040', teal:'#20a8a0', yellow:'#c8960c', indigo:'#5b6abf' };
const rows = scheduleData.rows || scheduleData || [];
kidGrid.innerHTML = '';
for (const s of students) {
const kidRows = rows.filter(r => r.student_id === s.id);
const total = kidRows.length;
const done = kidRows.filter(r => r.done).length;
const pct = total ? Math.round(done / total * 100) : 0;
const accent = THEME_HEX[s.theme?.replace('theme-','')] || '#4f7ef8';
kidGrid.innerHTML += `
<div class="dash-kid-card" style="--kid-accent:${accent}">
<div class="dash-kid-top">
<span class="dash-kid-avatar">${s.emoji || '🧒'}</span>
<div>
<div class="dash-kid-name">${s.name}</div>
<div class="dash-kid-streak">🔥 ${s.streak_count || 0}-day streak</div>
</div>
</div>
<div class="dash-kid-stats">
<div class="dash-stat-box">
<div class="dash-stat-num">${done}</div>
<div class="dash-stat-lbl">Done today</div>
</div>
<div class="dash-stat-box">
<div class="dash-stat-num">${total}</div>
<div class="dash-stat-lbl">Scheduled</div>
</div>
</div>
<div class="dash-progress-bar">
<div class="dash-progress-fill" style="width:${pct}%"></div>
</div>
</div>`;
}
schedEl.innerHTML = rows.length ? rows.slice(0, 12).map(r => {
const kidName = students.find(s => s.id === r.student_id)?.name || '';
return `<div class="dash-schedule-item${r.done ? ' done' : ''}">
<span class="sch-dot"></span>
<span>${r.title}</span>
<span class="dash-schedule-who">${kidName}</span>
</div>`;
}).join('') : '<p style="color:var(--muted);font-size:.82rem">No schedule for today.</p>';
const scores = recentScores.scores || recentScores || [];
scoresEl.innerHTML = scores.length ? scores.slice(0, 8).map(sc => {
const pct = sc.total > 0 ? Math.round(sc.score / sc.total * 100) : null;
const cls = pct === null ? '' : pct >= 80 ? '' : pct >= 60 ? ' mid' : ' low';
const kidName = students.find(s => s.id === sc.student_id)?.name || '';
return `<div class="dash-score-item">
<span>${sc.title}</span>
<span class="dash-score-who" style="font-size:.7rem;color:var(--muted);margin-left:.25rem">${kidName}</span>
<span class="dash-score-pct${cls}">${pct !== null ? pct+'%' : sc.score+' pts'}</span>
</div>`;
}).join('') : '<p style="color:var(--muted);font-size:.82rem">No scores yet.</p>';
} catch (e) {
kidGrid.innerHTML = '<p style="color:var(--muted)">Could not load dashboard data.</p>';
}
}
// ============================================================
// Kids
// ============================================================
@@ -1310,8 +1415,8 @@ $('#planner-student').addEventListener('change', () => {
});
$('#planner-view-week').addEventListener('click', () => {
plannerMode = 'week';
$('#planner-view-week').classList.add('active-view');
$('#planner-view-month').classList.remove('active-view');
$('#planner-view-week').classList.add('active');
$('#planner-view-month').classList.remove('active');
$('#planner-copy').hidden = false;
renderPlanner();
});
@@ -1321,8 +1426,8 @@ $('#planner-view-month').addEventListener('click', () => {
const ref = new Date(plannerWeekStart + 'T00:00:00');
plannerMonthRef = `${ref.getFullYear()}-${String(ref.getMonth() + 1).padStart(2, '0')}-01`;
plannerWeekStart = firstWeekStartOfMonth(plannerMonthRef);
$('#planner-view-month').classList.add('active-view');
$('#planner-view-week').classList.remove('active-view');
$('#planner-view-month').classList.add('active');
$('#planner-view-week').classList.remove('active');
$('#planner-copy').hidden = true;
renderPlanner();
});
@@ -1455,26 +1560,53 @@ function renderDayCell(day, tasks, compact = false, spellingTests = [], today =
const dayTasks = tasks.filter((t) => t.date === day.date);
const dayTests = spellingTests.filter((t) => t.date === day.date);
const isToday = day.date === today;
const isPast = day.date < today;
const doneCount = dayTasks.filter(t => t.done).length;
const total = dayTasks.length + dayTests.length;
const testBadges = dayTests.map((t) => {
const pct = Math.round((t.score / t.total) * 100);
return `<div class="planner-task planner-test-badge" title="${esc(t.list)}">⭐ Spelling ${t.score}/${t.total} (${pct}%)</div>`;
}).join('');
const taskHtml = dayTasks.map((t) => {
const colorStyle = t.courseColor ? ` style="border-left:3px solid ${esc(t.courseColor)}"` : '';
return `<div class="planner-task${t.done ? ' done' : ''}" draggable="true" data-task-id="${t.id}"${colorStyle}>
<button class="planner-done-btn${t.done ? ' is-done' : ''}" data-done-schedule="${t.id}" data-is-done="${t.done ? '1' : '0'}" title="${t.done ? 'Mark not done' : 'Mark done'}">✓</button>
<span>${t.itemId ? TYPE_ICON[t.type] : '📌'} ${esc(t.itemTitle || t.offlineTitle)}</span>
<button class="danger tiny" data-del-schedule="${t.id}">✕</button>
const cls = pct >= 80 ? 'good' : pct >= 60 ? 'mid' : 'bad';
return `<div class="planner-task planner-test-badge">
<span class="task-icon">✏️</span>
<span class="task-title">Spelling test</span>
<span class="task-score ${cls}">${pct}%</span>
</div>`;
}).join('');
const taskHtml = dayTasks.map((t) => {
const border = t.courseColor ? `border-left-color:${esc(t.courseColor)}` : '';
const courseName = t.courseName ? `<span class="task-course">${esc(t.courseName)}</span>` : '';
return `<div class="planner-task${t.done ? ' done' : ''}" draggable="true" data-task-id="${t.id}" style="${border}">
<button class="planner-done-btn${t.done ? ' is-done' : ''}" data-done-schedule="${t.id}" data-is-done="${t.done ? '1' : '0'}" title="${t.done ? 'Undo' : 'Mark done'}">✓</button>
<div class="task-body">
<span class="task-title">${t.itemId ? TYPE_ICON[t.type] || '' : '📌'} ${esc(t.itemTitle || t.offlineTitle)}</span>
${courseName}
</div>
<button class="planner-del-btn" data-del-schedule="${t.id}" title="Remove">✕</button>
</div>`;
}).join('');
const progress = total > 0
? `<span class="day-progress${doneCount === total ? ' all-done' : ''}">${doneCount}/${total}</span>`
: '';
const emptyState = !taskHtml && !testBadges
? `<button class="planner-empty-add" data-add-task="${day.date}">+ Add task</button>`
: '';
const fmtDate = (d) => { const dt = new Date(d + 'T00:00:00'); return dt.toLocaleString('default', { month: 'short', day: 'numeric' }); };
return `<div class="planner-day${compact ? ' planner-day-compact' : ''}${isToday ? ' today' : ''}" data-drop-date="${day.date}">
<h3>${compact ? day.name.slice(0,3) : day.name}<small>${fmtDate(day.date)}</small></h3>
<div class="planner-tasks" data-drop-date="${day.date}">
${testBadges}
${taskHtml || (!testBadges ? `<p class="hint tiny">—</p>` : '')}
return `<div class="planner-day${compact ? ' planner-day-compact' : ''}${isToday ? ' is-today' : ''}${isPast && !compact ? ' is-past' : ''}" data-drop-date="${day.date}">
<div class="planner-day-header">
<div class="day-label">
<span class="day-name">${compact ? day.name.slice(0,3) : day.name}</span>
<span class="day-date">${fmtDate(day.date)}</span>
</div>
${progress}
</div>
<button class="secondary small" data-add-task="${day.date}">+ Add</button>
<div class="planner-tasks" data-drop-date="${day.date}">
${testBadges}${taskHtml}${emptyState}
</div>
${taskHtml || testBadges ? `<button class="planner-add-more" data-add-task="${day.date}">+ Add</button>` : ''}
</div>`;
}