Planner UX improvements and grading max-points display

- Today button jumps planner to current week; today column highlighted with accent outline
- Week label now shows friendly format (Aug 3–7, 2026) instead of ISO dates
- Course color shown as left border on scheduled course items
- Parent-side done toggle on every planner task (works for offline + course items)
- Within-day drag-to-reorder tasks via new /api/schedule/reorder endpoint
- Grading queue shows visible "/ N pts" max-points label (defaults to 100 when unset)
- New item default points changed from 10 to 100

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-04 15:16:49 -04:00
1 parent 7580257ba0
commit 60a2a597c5
4 files changed
+112 -21

No files matched your search

+1
View File
@@ -334,6 +334,7 @@
<button id="planner-prev" class="secondary">← Prev</button> <button id="planner-prev" class="secondary">← Prev</button>
<span id="planner-week-label" class="week-label"></span> <span id="planner-week-label" class="week-label"></span>
<button id="planner-next" class="secondary">Next →</button> <button id="planner-next" class="secondary">Next →</button>
<button id="planner-today" class="secondary">Today</button>
<button id="planner-copy" class="secondary">Copy last week</button> <button id="planner-copy" class="secondary">Copy last week</button>
<button id="planner-print">🖨 Print</button> <button id="planner-print">🖨 Print</button>
<button id="planner-add-btn">+ Add task</button> <button id="planner-add-btn">+ Add task</button>
+86 -16
View File
@@ -533,7 +533,7 @@ async function openItemEditor(unitId, itemId) {
$('#ie-title').value = item.title; $('#ie-title').value = item.title;
$('#ie-body-lesson').innerHTML = item.body || ''; $('#ie-body-lesson').innerHTML = item.body || '';
$('#ie-body-assignment').innerHTML = item.body || ''; $('#ie-body-assignment').innerHTML = item.body || '';
$('#ie-points').value = item.points || 10; $('#ie-points').value = item.points || 100;
$('#ie-due-date').value = item.due_date || ''; $('#ie-due-date').value = item.due_date || '';
$('#ie-allow-retakes').checked = !!item.allow_retakes; $('#ie-allow-retakes').checked = !!item.allow_retakes;
$('#ie-prereq').value = item.prereq_item_id || ''; $('#ie-prereq').value = item.prereq_item_id || '';
@@ -582,7 +582,7 @@ async function openItemEditor(unitId, itemId) {
$('#ie-ws-paste').value = ''; $('#ie-ws-paste').value = '';
$('#ie-ws-editor').innerHTML = ''; $('#ie-ws-editor').innerHTML = '';
$('#ie-ws-parse-status').hidden = true; $('#ie-ws-parse-status').hidden = true;
$('#ie-points').value = 10; $('#ie-points').value = 100;
$('#ie-due-date').value = ''; $('#ie-due-date').value = '';
$('#ie-allow-retakes').checked = false; $('#ie-allow-retakes').checked = false;
$('#ie-prereq').value = ''; $('#ie-prereq').value = '';
@@ -1161,6 +1161,15 @@ function weekStartOf(d) {
date.setDate(date.getDate() - diff); date.setDate(date.getDate() - diff);
return date.toISOString().slice(0, 10); return date.toISOString().slice(0, 10);
} }
function formatWeekLabel(start, end) {
const s = new Date(start + 'T00:00:00');
const e = new Date(end + 'T00:00:00');
const sM = s.toLocaleString('default', { month: 'short' });
const eM = e.toLocaleString('default', { month: 'short' });
const y = s.getFullYear();
if (s.getMonth() === e.getMonth()) return `${sM} ${s.getDate()}–${e.getDate()}, ${y}`;
return `${sM} ${s.getDate()} – ${eM} ${e.getDate()}, ${y}`;
}
function addDays(dateStr, n) { function addDays(dateStr, n) {
const d = new Date(dateStr + 'T00:00:00'); const d = new Date(dateStr + 'T00:00:00');
d.setDate(d.getDate() + n); d.setDate(d.getDate() + n);
@@ -1266,6 +1275,11 @@ $('#planner-copy').addEventListener('click', async () => {
msg(`Copied ${copied} task${copied === 1 ? '' : 's'} from last week.`); msg(`Copied ${copied} task${copied === 1 ? '' : 's'} from last week.`);
renderPlanner(); renderPlanner();
}); });
$('#planner-today').addEventListener('click', () => {
plannerWeekStart = weekStartOf(new Date());
plannerMonthRef = plannerWeekStart;
renderPlanner();
});
$('#planner-print').addEventListener('click', () => { $('#planner-print').addEventListener('click', () => {
if (plannerMode === 'month') printMonthReport(plannerStudentId, plannerWeekStart, plannerMonthRef); if (plannerMode === 'month') printMonthReport(plannerStudentId, plannerWeekStart, plannerMonthRef);
@@ -1278,18 +1292,51 @@ const DAY_NAMES = () => appSettings.weekStartDay === 'sunday' ? DAY_NAMES_SUN :
function attachPlannerEvents() { function attachPlannerEvents() {
let draggingId = null; let draggingId = null;
let draggingDate = null;
document.querySelectorAll('.planner-task[draggable]').forEach((el) => { document.querySelectorAll('.planner-task[draggable]').forEach((el) => {
el.addEventListener('dragstart', (e) => { el.addEventListener('dragstart', (e) => {
draggingId = el.dataset.taskId; draggingId = el.dataset.taskId;
draggingDate = el.closest('.planner-tasks')?.dataset.dropDate;
el.classList.add('dragging'); el.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
}); });
el.addEventListener('dragend', () => { el.addEventListener('dragend', () => {
draggingId = null; draggingId = null;
draggingDate = null;
el.classList.remove('dragging'); el.classList.remove('dragging');
document.querySelectorAll('.planner-tasks').forEach((col) => col.classList.remove('drop-target')); document.querySelectorAll('.planner-tasks').forEach((col) => col.classList.remove('drop-target'));
document.querySelectorAll('.planner-task').forEach((t) => t.classList.remove('insert-before'));
});
el.addEventListener('dragover', (e) => {
if (!draggingId || el.dataset.taskId === draggingId) return;
e.preventDefault();
e.stopPropagation();
document.querySelectorAll('.planner-task').forEach((t) => t.classList.remove('insert-before'));
el.classList.add('insert-before');
});
el.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation();
document.querySelectorAll('.planner-task').forEach((t) => t.classList.remove('insert-before'));
if (!draggingId || el.dataset.taskId === draggingId) return;
const targetDate = el.closest('.planner-tasks')?.dataset.dropDate;
if (draggingDate === targetDate) {
const colEl = el.closest('.planner-tasks');
const ids = [...colEl.querySelectorAll('.planner-task[data-task-id]')].map((t) => t.dataset.taskId);
const fromIdx = ids.indexOf(draggingId);
const toIdx = ids.indexOf(el.dataset.taskId);
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return;
ids.splice(fromIdx, 1);
ids.splice(toIdx, 0, draggingId);
await api('/api/schedule/reorder', { method: 'POST', body: { tasks: ids.map((id, sort) => ({ id: Number(id), sort })) } });
} else {
await api(`/api/schedule/${draggingId}`, { method: 'PATCH', body: { date: targetDate } });
}
renderPlanner();
}); });
}); });
document.querySelectorAll('.planner-tasks').forEach((col) => { document.querySelectorAll('.planner-tasks').forEach((col) => {
col.addEventListener('dragover', (e) => { col.addEventListener('dragover', (e) => {
e.preventDefault(); e.preventDefault();
@@ -1301,36 +1348,54 @@ function attachPlannerEvents() {
e.preventDefault(); e.preventDefault();
col.classList.remove('drop-target'); col.classList.remove('drop-target');
if (!draggingId) return; if (!draggingId) return;
await api(`/api/schedule/${draggingId}`, { method: 'PATCH', body: { date: col.dataset.dropDate } }); const targetDate = col.dataset.dropDate;
renderPlanner(); if (targetDate !== draggingDate) {
await api(`/api/schedule/${draggingId}`, { method: 'PATCH', body: { date: targetDate } });
renderPlanner();
}
}); });
}); });
document.querySelectorAll('[data-del-schedule]').forEach((btn) => document.querySelectorAll('[data-del-schedule]').forEach((btn) =>
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
await api(`/api/schedule/${btn.dataset.delSchedule}`, { method: 'DELETE' }); await api(`/api/schedule/${btn.dataset.delSchedule}`, { method: 'DELETE' });
renderPlanner(); renderPlanner();
}) })
); );
document.querySelectorAll('[data-done-schedule]').forEach((btn) => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const isDone = btn.dataset.isDone === '1';
await api(`/api/schedule/${btn.dataset.doneSchedule}`, { method: 'PATCH', body: { done: !isDone } });
renderPlanner();
});
});
document.querySelectorAll('[data-add-task]').forEach((btn) => document.querySelectorAll('[data-add-task]').forEach((btn) =>
btn.addEventListener('click', () => openPlannerAddModal(btn.dataset.addTask)) btn.addEventListener('click', () => openPlannerAddModal(btn.dataset.addTask))
); );
} }
function renderDayCell(day, tasks, compact = false, spellingTests = []) { function renderDayCell(day, tasks, compact = false, spellingTests = [], today = '') {
const dayTasks = tasks.filter((t) => t.date === day.date); const dayTasks = tasks.filter((t) => t.date === day.date);
const dayTests = spellingTests.filter((t) => t.date === day.date); const dayTests = spellingTests.filter((t) => t.date === day.date);
const isToday = day.date === today;
const testBadges = dayTests.map((t) => { const testBadges = dayTests.map((t) => {
const pct = Math.round((t.score / t.total) * 100); 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>`; return `<div class="planner-task planner-test-badge" title="${esc(t.list)}">⭐ Spelling ${t.score}/${t.total} (${pct}%)</div>`;
}).join(''); }).join('');
return `<div class="planner-day${compact ? ' planner-day-compact' : ''}" data-drop-date="${day.date}"> 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>
</div>`;
}).join('');
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>${compact ? day.date.slice(5) : day.date}</small></h3> <h3>${compact ? day.name.slice(0,3) : day.name}<small>${compact ? day.date.slice(5) : day.date}</small></h3>
<div class="planner-tasks" data-drop-date="${day.date}"> <div class="planner-tasks" data-drop-date="${day.date}">
${testBadges} ${testBadges}
${dayTasks.map((t) => `<div class="planner-task ${t.done ? 'done' : ''}" draggable="true" data-task-id="${t.id}"> ${taskHtml || (!testBadges ? `<p class="hint tiny">—</p>` : '')}
<span>${t.itemId ? TYPE_ICON[t.type] : '📌'} ${esc(t.itemTitle || t.offlineTitle)}</span>
<button class="danger tiny" data-del-schedule="${t.id}">✕</button>
</div>`).join('') || (!testBadges ? `<p class="hint tiny">—</p>` : '')}
</div> </div>
<button class="secondary small" data-add-task="${day.date}">+ Add</button> <button class="secondary small" data-add-task="${day.date}">+ Add</button>
</div>`; </div>`;
@@ -1360,23 +1425,25 @@ async function renderPlanner() {
api(`/api/admin/students/${plannerStudentId}/tests-range?from=${rangeStart}&to=${rangeEnd}`), api(`/api/admin/students/${plannerStudentId}/tests-range?from=${rangeStart}&to=${rangeEnd}`),
]); ]);
const today = new Date().toISOString().slice(0, 10);
$('#planner-week-label').textContent = monthLabel(plannerMonthRef); $('#planner-week-label').textContent = monthLabel(plannerMonthRef);
$('#planner-grid').className = 'planner-grid planner-month'; $('#planner-grid').className = 'planner-grid planner-month';
$('#planner-grid').innerHTML = $('#planner-grid').innerHTML =
`<div class="planner-month-header">${DAY_NAMES().map((d) => `<div>${d.slice(0,3)}</div>`).join('')}</div>` + `<div class="planner-month-header">${DAY_NAMES().map((d) => `<div>${d.slice(0,3)}</div>`).join('')}</div>` +
weeks.map((week) => weeks.map((week) =>
`<div class="planner-month-row">${week.map((day) => renderDayCell(day, tasks, true, spellingTests)).join('')}</div>` `<div class="planner-month-row">${week.map((day) => renderDayCell(day, tasks, true, spellingTests, today)).join('')}</div>`
).join(''); ).join('');
} else { } else {
const end = addDays(plannerWeekStart, 4); const end = addDays(plannerWeekStart, 4);
$('#planner-week-label').textContent = `${plannerWeekStart} → ${end}`; $('#planner-week-label').textContent = formatWeekLabel(plannerWeekStart, end);
const [{ tasks }, { tests: spellingTests }] = await Promise.all([ const [{ tasks }, { tests: spellingTests }] = await Promise.all([
api(`/api/schedule-week/${plannerStudentId}?start=${plannerWeekStart}`), api(`/api/schedule-week/${plannerStudentId}?start=${plannerWeekStart}`),
api(`/api/admin/students/${plannerStudentId}/tests-range?from=${plannerWeekStart}&to=${end}`), api(`/api/admin/students/${plannerStudentId}/tests-range?from=${plannerWeekStart}&to=${end}`),
]); ]);
const today = new Date().toISOString().slice(0, 10);
const days = DAY_NAMES().map((name, i) => ({ name, date: addDays(plannerWeekStart, i) })); const days = DAY_NAMES().map((name, i) => ({ name, date: addDays(plannerWeekStart, i) }));
$('#planner-grid').className = 'planner-grid'; $('#planner-grid').className = 'planner-grid';
$('#planner-grid').innerHTML = days.map((day) => renderDayCell(day, tasks, false, spellingTests)).join(''); $('#planner-grid').innerHTML = days.map((day) => renderDayCell(day, tasks, false, spellingTests, today)).join('');
} }
attachPlannerEvents(); attachPlannerEvents();
@@ -1384,7 +1451,7 @@ async function renderPlanner() {
// ---------- planner add-task modal ---------- // ---------- planner add-task modal ----------
let allCourseItems = []; // {id, type, title, courseName} let allCourseItems = []; // {id, type, title, courseName, courseColor}
let plannerAddDate = null; let plannerAddDate = null;
async function loadAllCourseItems() { async function loadAllCourseItems() {
@@ -1394,7 +1461,7 @@ async function loadAllCourseItems() {
for (const course of details) { for (const course of details) {
for (const unit of course.units) { for (const unit of course.units) {
for (const it of unit.items) { for (const it of unit.items) {
allCourseItems.push({ id: it.id, type: it.type, title: it.title, courseName: course.name }); allCourseItems.push({ id: it.id, type: it.type, title: it.title, courseName: course.name, courseColor: course.color });
} }
} }
} }
@@ -1734,7 +1801,10 @@ async function loadGrading() {
</div> </div>
${evidenceBtn} ${evidenceBtn}
<div class="grading-footer"> <div class="grading-footer">
<input type="number" class="grade-input" min="0" max="${r.points_possible}" placeholder="Score / ${r.points_possible || '?'}" data-sub="${r.submissionId}"> <div class="grade-score-row">
<input type="number" class="grade-input" min="0" max="${r.points_possible || 100}" placeholder="Score" data-sub="${r.submissionId}">
<span class="grade-max-label">/ ${r.points_possible || 100} pts</span>
</div>
<input type="text" class="comment-input" placeholder="Parent comment (optional)" data-comment-sub="${r.submissionId}"> <input type="text" class="comment-input" placeholder="Parent comment (optional)" data-comment-sub="${r.submissionId}">
<button data-save-grade="${r.submissionId}">Save grade</button> <button data-save-grade="${r.submissionId}">Save grade</button>
</div> </div>
+7
View File
@@ -653,6 +653,11 @@ button.tiny { font-size: 0.75rem; padding: 0.15rem 0.5rem; }
.planner-task[draggable] { cursor: grab; } .planner-task[draggable] { cursor: grab; }
.planner-task[draggable]:active { cursor: grabbing; } .planner-task[draggable]:active { cursor: grabbing; }
.planner-task.dragging { opacity: 0.35; } .planner-task.dragging { opacity: 0.35; }
.planner-task.insert-before { border-top: 2px solid var(--accent); }
.planner-done-btn { flex-shrink: 0; width: 20px; height: 20px; padding: 0; border: 1.5px solid #bbb; border-radius: 4px; background: transparent; color: transparent; font-size: 0.72rem; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: border-color 0.15s, color 0.15s, background 0.15s; }
.planner-done-btn.is-done { border-color: var(--good); color: var(--good); background: var(--good-bg); }
.planner-day.today { outline: 2px solid var(--accent); outline-offset: 2px; }
.planner-day.today > h3 { color: var(--accent); }
.planner-test-badge { background: color-mix(in srgb, var(--accent) 18%, var(--card)); border-left: 3px solid var(--accent); font-size: 0.78rem; cursor: default; pointer-events: none; } .planner-test-badge { background: color-mix(in srgb, var(--accent) 18%, var(--card)); border-left: 3px solid var(--accent); font-size: 0.78rem; cursor: default; pointer-events: none; }
.planner-tasks.drop-target { .planner-tasks.drop-target {
background: color-mix(in srgb, var(--accent) 12%, transparent); background: color-mix(in srgb, var(--accent) 12%, transparent);
@@ -665,6 +670,8 @@ button.tiny { font-size: 0.75rem; padding: 0.15rem 0.5rem; }
.task-save-btn { font-size: 0.85rem; } .task-save-btn { font-size: 0.85rem; }
.grade-input { width: 4.5rem; padding: 0.4rem; border: 2px solid #d8d2c0; border-radius: 8px; font-family: inherit; } .grade-input { width: 4.5rem; padding: 0.4rem; border: 2px solid #d8d2c0; border-radius: 8px; font-family: inherit; }
.grade-score-row { display: flex; align-items: center; gap: 0.4rem; }
.grade-max-label { font-size: 0.9rem; font-weight: 600; color: var(--ink); white-space: nowrap; }
@media (max-width: 700px) { @media (max-width: 700px) {
.planner-grid { grid-template-columns: 1fr; } .planner-grid { grid-template-columns: 1fr; }
+18 -5
View File
@@ -1912,9 +1912,22 @@ app.post('/api/schedule', requirePin, (req, res) => {
}); });
app.patch('/api/schedule/:id', requirePin, (req, res) => { app.patch('/api/schedule/:id', requirePin, (req, res) => {
const { date } = req.body; const { date, done } = req.body;
if (!isDateStr(date)) return res.status(400).json({ error: 'date=YYYY-MM-DD required' }); if (date !== undefined) {
db.prepare(`UPDATE schedule SET date = ? WHERE id = ?`).run(date, req.params.id); if (!isDateStr(date)) return res.status(400).json({ error: 'date=YYYY-MM-DD required' });
db.prepare(`UPDATE schedule SET date = ? WHERE id = ?`).run(date, req.params.id);
}
if (done !== undefined) {
const d = done ? 1 : 0;
db.prepare(`UPDATE schedule SET done = ?, done_at = CASE WHEN ? THEN datetime('now') ELSE NULL END WHERE id = ?`).run(d, d, req.params.id);
}
res.json({ ok: true });
});
app.post('/api/schedule/reorder', requirePin, (req, res) => {
const stmt = db.prepare(`UPDATE schedule SET sort = ? WHERE id = ?`);
const run = db.transaction((tasks) => { for (const t of tasks) stmt.run(t.sort, t.id); });
run(req.body.tasks || []);
res.json({ ok: true }); res.json({ ok: true });
}); });
@@ -1929,7 +1942,7 @@ app.get('/api/schedule-week/:studentId', requirePin, (req, res) => {
if (!start) return res.status(400).json({ error: 'start=YYYY-MM-DD required' }); if (!start) return res.status(400).json({ error: 'start=YYYY-MM-DD required' });
const tasks = db.prepare(` const tasks = db.prepare(`
SELECT sc.id, sc.date, sc.title AS offlineTitle, sc.done, SELECT sc.id, sc.date, sc.title AS offlineTitle, sc.done,
i.id AS itemId, i.type, i.title AS itemTitle, c.name AS courseName i.id AS itemId, i.type, i.title AS itemTitle, c.name AS courseName, c.color AS courseColor
FROM schedule sc FROM schedule sc
LEFT JOIN items i ON i.id = sc.item_id LEFT JOIN items i ON i.id = sc.item_id
LEFT JOIN units u ON u.id = i.unit_id LEFT JOIN units u ON u.id = i.unit_id
@@ -1946,7 +1959,7 @@ app.get('/api/schedule-range/:studentId', requirePin, (req, res) => {
if (!start || !end) return res.status(400).json({ error: 'start and end (YYYY-MM-DD) required' }); if (!start || !end) return res.status(400).json({ error: 'start and end (YYYY-MM-DD) required' });
const tasks = db.prepare(` const tasks = db.prepare(`
SELECT sc.id, sc.date, sc.title AS offlineTitle, sc.done, SELECT sc.id, sc.date, sc.title AS offlineTitle, sc.done,
i.id AS itemId, i.type, i.title AS itemTitle, i.body, c.name AS courseName i.id AS itemId, i.type, i.title AS itemTitle, i.body, c.name AS courseName, c.color AS courseColor
FROM schedule sc FROM schedule sc
LEFT JOIN items i ON i.id = sc.item_id LEFT JOIN items i ON i.id = sc.item_id
LEFT JOIN units u ON u.id = i.unit_id LEFT JOIN units u ON u.id = i.unit_id