diff --git a/public/admin.html b/public/admin.html index f0ecf29..e9d04ab 100644 --- a/public/admin.html +++ b/public/admin.html @@ -334,6 +334,7 @@ + diff --git a/public/admin.js b/public/admin.js index 272103f..28e56de 100644 --- a/public/admin.js +++ b/public/admin.js @@ -533,7 +533,7 @@ async function openItemEditor(unitId, itemId) { $('#ie-title').value = item.title; $('#ie-body-lesson').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-allow-retakes').checked = !!item.allow_retakes; $('#ie-prereq').value = item.prereq_item_id || ''; @@ -582,7 +582,7 @@ async function openItemEditor(unitId, itemId) { $('#ie-ws-paste').value = ''; $('#ie-ws-editor').innerHTML = ''; $('#ie-ws-parse-status').hidden = true; - $('#ie-points').value = 10; + $('#ie-points').value = 100; $('#ie-due-date').value = ''; $('#ie-allow-retakes').checked = false; $('#ie-prereq').value = ''; @@ -1161,6 +1161,15 @@ function weekStartOf(d) { date.setDate(date.getDate() - diff); 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) { const d = new Date(dateStr + 'T00:00:00'); d.setDate(d.getDate() + n); @@ -1266,6 +1275,11 @@ $('#planner-copy').addEventListener('click', async () => { msg(`Copied ${copied} task${copied === 1 ? '' : 's'} from last week.`); renderPlanner(); }); +$('#planner-today').addEventListener('click', () => { + plannerWeekStart = weekStartOf(new Date()); + plannerMonthRef = plannerWeekStart; + renderPlanner(); +}); $('#planner-print').addEventListener('click', () => { if (plannerMode === 'month') printMonthReport(plannerStudentId, plannerWeekStart, plannerMonthRef); @@ -1278,18 +1292,51 @@ const DAY_NAMES = () => appSettings.weekStartDay === 'sunday' ? DAY_NAMES_SUN : function attachPlannerEvents() { let draggingId = null; + let draggingDate = null; + document.querySelectorAll('.planner-task[draggable]').forEach((el) => { el.addEventListener('dragstart', (e) => { draggingId = el.dataset.taskId; + draggingDate = el.closest('.planner-tasks')?.dataset.dropDate; el.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; }); el.addEventListener('dragend', () => { draggingId = null; + draggingDate = null; el.classList.remove('dragging'); 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) => { col.addEventListener('dragover', (e) => { e.preventDefault(); @@ -1301,36 +1348,54 @@ function attachPlannerEvents() { e.preventDefault(); col.classList.remove('drop-target'); if (!draggingId) return; - await api(`/api/schedule/${draggingId}`, { method: 'PATCH', body: { date: col.dataset.dropDate } }); - renderPlanner(); + const targetDate = col.dataset.dropDate; + if (targetDate !== draggingDate) { + await api(`/api/schedule/${draggingId}`, { method: 'PATCH', body: { date: targetDate } }); + renderPlanner(); + } }); }); + document.querySelectorAll('[data-del-schedule]').forEach((btn) => btn.addEventListener('click', async () => { await api(`/api/schedule/${btn.dataset.delSchedule}`, { method: 'DELETE' }); 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) => 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 dayTests = spellingTests.filter((t) => t.date === day.date); + const isToday = day.date === today; const testBadges = dayTests.map((t) => { const pct = Math.round((t.score / t.total) * 100); return `
⭐ Spelling ${t.score}/${t.total} (${pct}%)
`; }).join(''); - return `
+ const taskHtml = dayTasks.map((t) => { + const colorStyle = t.courseColor ? ` style="border-left:3px solid ${esc(t.courseColor)}"` : ''; + return `
+ + ${t.itemId ? TYPE_ICON[t.type] : '📌'} ${esc(t.itemTitle || t.offlineTitle)} + +
`; + }).join(''); + return `

${compact ? day.name.slice(0,3) : day.name}${compact ? day.date.slice(5) : day.date}

${testBadges} - ${dayTasks.map((t) => `
- ${t.itemId ? TYPE_ICON[t.type] : '📌'} ${esc(t.itemTitle || t.offlineTitle)} - -
`).join('') || (!testBadges ? `

—

` : '')} + ${taskHtml || (!testBadges ? `

—

` : '')}
`; @@ -1360,23 +1425,25 @@ async function renderPlanner() { 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-grid').className = 'planner-grid planner-month'; $('#planner-grid').innerHTML = `
${DAY_NAMES().map((d) => `
${d.slice(0,3)}
`).join('')}
` + weeks.map((week) => - `
${week.map((day) => renderDayCell(day, tasks, true, spellingTests)).join('')}
` + `
${week.map((day) => renderDayCell(day, tasks, true, spellingTests, today)).join('')}
` ).join(''); } else { 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([ api(`/api/schedule-week/${plannerStudentId}?start=${plannerWeekStart}`), 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) })); $('#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(); @@ -1384,7 +1451,7 @@ async function renderPlanner() { // ---------- planner add-task modal ---------- -let allCourseItems = []; // {id, type, title, courseName} +let allCourseItems = []; // {id, type, title, courseName, courseColor} let plannerAddDate = null; async function loadAllCourseItems() { @@ -1394,7 +1461,7 @@ async function loadAllCourseItems() { for (const course of details) { for (const unit of course.units) { 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() {
${evidenceBtn} diff --git a/public/styles.css b/public/styles.css index 8aa8a72..2f2424f 100644 --- a/public/styles.css +++ b/public/styles.css @@ -653,6 +653,11 @@ button.tiny { font-size: 0.75rem; padding: 0.15rem 0.5rem; } .planner-task[draggable] { cursor: grab; } .planner-task[draggable]:active { cursor: grabbing; } .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-tasks.drop-target { 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; } .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) { .planner-grid { grid-template-columns: 1fr; } diff --git a/server.js b/server.js index c3c6798..5c8bedb 100644 --- a/server.js +++ b/server.js @@ -1912,9 +1912,22 @@ app.post('/api/schedule', requirePin, (req, res) => { }); app.patch('/api/schedule/:id', requirePin, (req, res) => { - const { date } = req.body; - 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); + const { date, done } = req.body; + if (date !== undefined) { + 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 }); }); @@ -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' }); const tasks = db.prepare(` 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 LEFT JOIN items i ON i.id = sc.item_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' }); const tasks = db.prepare(` 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 LEFT JOIN items i ON i.id = sc.item_id LEFT JOIN units u ON u.id = i.unit_id