From 19fe0cb0533389895f1d2c3021def9ec795957e5 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:47:28 -0400 Subject: [PATCH] Add month planner view and print for courses and planner - Month view toggle in planner (Mon-Fri calendar grid, 4-5 weeks) - Prev/Next navigate by month when in month mode - Print month report (landscape calendar layout) - Print course button on course detail (all units + items) - New GET /api/schedule-range endpoint for arbitrary date spans Co-Authored-By: Claude Sonnet 4.6 --- public/admin.html | 14 +-- public/admin.js | 226 ++++++++++++++++++++++++++++++++++++++-------- public/styles.css | 15 +++ server.js | 17 ++++ 4 files changed, 226 insertions(+), 46 deletions(-) diff --git a/public/admin.html b/public/admin.html index d4a06b9..a9b2888 100644 --- a/public/admin.html +++ b/public/admin.html @@ -86,7 +86,7 @@
-

Units & content

+

Units & content

@@ -200,14 +200,16 @@
-

Weekly Planner

+

Planner

- + + + - - - + + +
diff --git a/public/admin.js b/public/admin.js index bc8c0d6..f189528 100644 --- a/public/admin.js +++ b/public/admin.js @@ -259,6 +259,8 @@ async function openCourseDetail(id) { btn.addEventListener('click', () => openItemEditor(btn.dataset.unitId, btn.dataset.editItem)) ); + $('#cd-print').onclick = () => printCourse(id); + document.querySelectorAll('.panel').forEach((p) => (p.hidden = p.id !== 'panel-course-detail')); document.querySelectorAll('.admin-nav .nav-pill').forEach((p) => p.classList.remove('active')); } @@ -640,6 +642,22 @@ function addDays(dateStr, n) { let plannerWeekStart = mondayOf(new Date()); let plannerStudentId = null; let plannerCoursesCache = []; +let plannerMode = 'week'; // 'week' | 'month' +let plannerMonthRef = null; // YYYY-MM-01 of the month being viewed + +function firstMondayOfMonth(dateStr) { + const d = new Date(dateStr + 'T00:00:00'); + d.setDate(1); + return mondayOf(d); +} +function addMonths(dateStr, n) { + const d = new Date(dateStr + 'T00:00:00'); + d.setMonth(d.getMonth() + n); + return d.toISOString().slice(0, 10); +} +function monthLabel(dateStr) { + return new Date(dateStr + 'T00:00:00').toLocaleString('default', { month: 'long', year: 'numeric' }); +} async function loadPlannerPanel() { const [students, courses] = await Promise.all([api('/api/students'), api('/api/admin/courses')]); @@ -673,8 +691,42 @@ $('#planner-student').addEventListener('change', () => { plannerStudentId = Number($('#planner-student').value); renderPlanner(); }); -$('#planner-prev').addEventListener('click', () => { plannerWeekStart = addDays(plannerWeekStart, -7); renderPlanner(); }); -$('#planner-next').addEventListener('click', () => { plannerWeekStart = addDays(plannerWeekStart, 7); renderPlanner(); }); +$('#planner-view-week').addEventListener('click', () => { + plannerMode = 'week'; + $('#planner-view-week').classList.add('active-view'); + $('#planner-view-month').classList.remove('active-view'); + $('#planner-copy').hidden = false; + renderPlanner(); +}); +$('#planner-view-month').addEventListener('click', () => { + plannerMode = 'month'; + // derive the month from the current week view date + const ref = new Date(plannerWeekStart + 'T00:00:00'); + plannerMonthRef = `${ref.getFullYear()}-${String(ref.getMonth() + 1).padStart(2, '0')}-01`; + plannerWeekStart = firstMondayOfMonth(plannerMonthRef); + $('#planner-view-month').classList.add('active-view'); + $('#planner-view-week').classList.remove('active-view'); + $('#planner-copy').hidden = true; + renderPlanner(); +}); +$('#planner-prev').addEventListener('click', () => { + if (plannerMode === 'month') { + plannerMonthRef = addMonths(plannerMonthRef, -1); + plannerWeekStart = firstMondayOfMonth(plannerMonthRef); + } else { + plannerWeekStart = addDays(plannerWeekStart, -7); + } + renderPlanner(); +}); +$('#planner-next').addEventListener('click', () => { + if (plannerMode === 'month') { + plannerMonthRef = addMonths(plannerMonthRef, 1); + plannerWeekStart = firstMondayOfMonth(plannerMonthRef); + } else { + plannerWeekStart = addDays(plannerWeekStart, 7); + } + renderPlanner(); +}); $('#planner-copy').addEventListener('click', async () => { const from = addDays(plannerWeekStart, -7); @@ -683,44 +735,15 @@ $('#planner-copy').addEventListener('click', async () => { renderPlanner(); }); -$('#planner-print').addEventListener('click', () => printWeekReport(plannerStudentId, plannerWeekStart)); +$('#planner-print').addEventListener('click', () => { + if (plannerMode === 'month') printMonthReport(plannerStudentId, plannerWeekStart, plannerMonthRef); + else printWeekReport(plannerStudentId, plannerWeekStart); +}); const DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']; -async function renderPlanner() { - const end = addDays(plannerWeekStart, 4); - $('#planner-week-label').textContent = `${plannerWeekStart} → ${end}`; - const [{ tasks }, courses] = await Promise.all([ - api(`/api/schedule-week/${plannerStudentId}?start=${plannerWeekStart}`), - api(`/api/courses/mine/${plannerStudentId}`), - ]); - plannerCoursesCache = courses; - - const days = DAY_NAMES.map((name, i) => ({ name, date: addDays(plannerWeekStart, i) })); - $('#planner-grid').innerHTML = days - .map((day) => { - const dayTasks = tasks.filter((t) => t.date === day.date); - return `
-

${day.name}${day.date}

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

Nothing planned

'} -
- - -
`; - }) - .join(''); - - // Drag-and-drop: tasks between days +function attachPlannerEvents() { let draggingId = null; - document.querySelectorAll('.planner-task[draggable]').forEach((el) => { el.addEventListener('dragstart', (e) => { draggingId = el.dataset.taskId; @@ -733,7 +756,6 @@ async function renderPlanner() { document.querySelectorAll('.planner-tasks').forEach((col) => col.classList.remove('drop-target')); }); }); - document.querySelectorAll('.planner-tasks').forEach((col) => { col.addEventListener('dragover', (e) => { e.preventDefault(); @@ -745,12 +767,10 @@ async function renderPlanner() { e.preventDefault(); col.classList.remove('drop-target'); if (!draggingId) return; - const newDate = col.dataset.dropDate; - await api(`/api/schedule/${draggingId}`, { method: 'PATCH', body: { date: newDate } }); + await api(`/api/schedule/${draggingId}`, { method: 'PATCH', body: { date: col.dataset.dropDate } }); renderPlanner(); }); }); - document.querySelectorAll('[data-del-schedule]').forEach((btn) => btn.addEventListener('click', async () => { await api(`/api/schedule/${btn.dataset.delSchedule}`, { method: 'DELETE' }); @@ -762,6 +782,61 @@ async function renderPlanner() { ); } +function renderDayCell(day, tasks, compact = false) { + const dayTasks = tasks.filter((t) => t.date === day.date); + return `
+

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

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

—

`} +
+ + +
`; +} + +async function renderPlanner() { + const courses = await api(`/api/courses/mine/${plannerStudentId}`); + plannerCoursesCache = courses; + + if (plannerMode === 'month') { + // Build 4-5 week rows covering the month + const month = new Date(plannerMonthRef + 'T00:00:00').getMonth(); + const weeks = []; + let cursor = plannerWeekStart; + for (let w = 0; w < 6; w++) { + const weekDays = DAY_NAMES.map((name, i) => ({ name, date: addDays(cursor, i) })); + const fridayMonth = new Date(weekDays[4].date + 'T00:00:00').getMonth(); + const mondayMonth = new Date(weekDays[0].date + 'T00:00:00').getMonth(); + if (w > 0 && mondayMonth !== month && fridayMonth !== month) break; + weeks.push(weekDays); + cursor = addDays(cursor, 7); + } + const rangeStart = weeks[0][0].date; + const rangeEnd = weeks[weeks.length - 1][4].date; + const { tasks } = await api(`/api/schedule-range/${plannerStudentId}?start=${rangeStart}&end=${rangeEnd}`); + + $('#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)).join('')}
` + ).join(''); + } else { + const end = addDays(plannerWeekStart, 4); + $('#planner-week-label').textContent = `${plannerWeekStart} → ${end}`; + const { tasks } = await api(`/api/schedule-week/${plannerStudentId}?start=${plannerWeekStart}`); + 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)).join(''); + } + + attachPlannerEvents(); +} + function toggleAddTaskForm(date) { const box = document.querySelector(`[data-form-for="${date}"]`); if (!box.hidden) { box.hidden = true; return; } @@ -837,6 +912,77 @@ async function printWeekReport(studentId, start) { win.document.close(); } +async function printMonthReport(studentId, monthMonday, monthRef) { + const month = new Date(monthRef + 'T00:00:00').getMonth(); + const weeks = []; + let cursor = monthMonday; + for (let w = 0; w < 6; w++) { + const weekDays = DAY_NAMES.map((name, i) => ({ name, date: addDays(cursor, i) })); + const fridayMonth = new Date(weekDays[4].date + 'T00:00:00').getMonth(); + const mondayMonth = new Date(weekDays[0].date + 'T00:00:00').getMonth(); + if (w > 0 && mondayMonth !== month && fridayMonth !== month) break; + weeks.push(weekDays); + cursor = addDays(cursor, 7); + } + const rangeStart = weeks[0][0].date; + const rangeEnd = weeks[weeks.length - 1][4].date; + const student = (await api('/api/students')).find((s) => s.id === studentId); + const { tasks } = await api(`/api/schedule-range/${studentId}?start=${rangeStart}&end=${rangeEnd}`); + + const DAY_SHORT = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri']; + const calRows = weeks.map((week) => + `${week.map((day) => { + const dayTasks = tasks.filter((t) => t.date === day.date); + const items = dayTasks.map((t) => `
${t.itemTitle || t.offlineTitle}
`).join(''); + return `
${day.date.slice(5)}
${items || ''}`; + }).join('')}` + ).join(''); + + const win = window.open('', '_blank'); + win.document.write(`${monthLabel(monthRef)} — ${student?.name || ''} + +

${monthLabel(monthRef)} — ${esc(student?.name || '')}

+ ${DAY_SHORT.map((d) => ``).join('')}${calRows}
${d}
+