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 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-07-21 16:47:28 -04:00
1 parent 3c22fe05f8
commit 19fe0cb053
4 files changed
+226 -46

No files matched your search

+8 -6
View File
@@ -86,7 +86,7 @@
</div>
<div class="admin-card">
<h2>Units &amp; content</h2>
<h2>Units &amp; content <button type="button" id="cd-print" class="secondary small" style="float:right">🖨 Print course</button></h2>
<div id="cd-units"></div>
<form id="add-unit-form" class="inline-form">
<input id="new-unit-name" placeholder="Unit name (e.g. Week 1: Weather)" aria-label="Unit name">
@@ -200,14 +200,16 @@
<!-- Planner -->
<section id="panel-planner" class="panel">
<h1>Weekly Planner</h1>
<h1>Planner</h1>
<div class="inline-form">
<label>Kid <select id="planner-student"></select></label>
<button id="planner-prev" class="secondary">← Prev week</button>
<button id="planner-view-week" class="secondary active-view">Week</button>
<button id="planner-view-month" class="secondary">Month</button>
<button id="planner-prev" class="secondary">← Prev</button>
<span id="planner-week-label" class="week-label"></span>
<button id="planner-next" class="secondary">Next week →</button>
<button id="planner-copy" class="secondary">Copy last week here</button>
<button id="planner-print">🖨 Print week report</button>
<button id="planner-next" class="secondary">Next →</button>
<button id="planner-copy" class="secondary">Copy last week</button>
<button id="planner-print">🖨 Print</button>
</div>
<details id="auto-schedule-details" class="admin-card" style="margin-bottom:1rem">
+186 -40
View File
@@ -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 `<div class="planner-day" data-drop-date="${day.date}">
<h3>${day.name}<small>${day.date}</small></h3>
<div class="planner-tasks" data-drop-date="${day.date}">
${dayTasks
.map(
(t) => `<div class="planner-task ${t.done ? 'done' : ''}" draggable="true" data-task-id="${t.id}">
<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('') || '<p class="hint tiny">Nothing planned</p>'}
</div>
<button class="secondary small" data-add-task="${day.date}">+ Add</button>
<div class="add-task-form" data-form-for="${day.date}" hidden></div>
</div>`;
})
.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 `<div class="planner-day${compact ? ' planner-day-compact' : ''}" data-drop-date="${day.date}">
<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}">
${dayTasks.map((t) => `<div class="planner-task ${t.done ? 'done' : ''}" draggable="true" data-task-id="${t.id}">
<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('') || `<p class="hint tiny">—</p>`}
</div>
<button class="secondary small" data-add-task="${day.date}">+ Add</button>
<div class="add-task-form" data-form-for="${day.date}" hidden></div>
</div>`;
}
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 =
`<div class="planner-month-header">${DAY_NAMES.map((d) => `<div>${d.slice(0,3)}</div>`).join('')}</div>` +
weeks.map((week) =>
`<div class="planner-month-row">${week.map((day) => renderDayCell(day, tasks, true)).join('')}</div>`
).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) =>
`<tr>${week.map((day) => {
const dayTasks = tasks.filter((t) => t.date === day.date);
const items = dayTasks.map((t) => `<div class="cell-task${t.done ? ' done' : ''}">${t.itemTitle || t.offlineTitle}</div>`).join('');
return `<td><div class="date-num">${day.date.slice(5)}</div>${items || ''}</td>`;
}).join('')}</tr>`
).join('');
const win = window.open('', '_blank');
win.document.write(`<!DOCTYPE html><html><head><title>${monthLabel(monthRef)} — ${student?.name || ''}</title>
<style>
body { font-family: Georgia, serif; margin: 1.5cm; color: #222; }
h1 { font-size: 1.4rem; border-bottom: 2px solid #222; padding-bottom: .4rem; margin-bottom: .75rem; }
table { width: 100%; border-collapse: collapse; }
th { background: #f0f0f0; padding: .4rem; font-size: .85rem; border: 1px solid #bbb; }
td { vertical-align: top; border: 1px solid #bbb; padding: .4rem; min-height: 3.5rem; width: 20%; }
.date-num { font-size: .75rem; color: #888; font-weight: 700; margin-bottom: .25rem; }
.cell-task { font-size: .78rem; margin: .1rem 0; padding: .1rem .3rem; background: #e8f0ff; border-radius: 3px; }
.cell-task.done { background: #e8f7ee; text-decoration: line-through; color: #666; }
@media print { @page { size: landscape; margin: 1cm; } }
</style></head><body>
<h1>${monthLabel(monthRef)} — ${esc(student?.name || '')}</h1>
<table><tr>${DAY_SHORT.map((d) => `<th>${d}</th>`).join('')}</tr>${calRows}</table>
<script>window.print()<\/script></body></html>`);
win.document.close();
}
async function printCourse(courseId) {
const course = await api(`/api/admin/courses/${courseId}`);
const unitBlocks = course.units.map((u) => {
const items = u.items.map((it) =>
`<tr><td>${TYPE_ICON[it.type] || ''} ${esc(it.title)}</td><td>${TYPE_LABEL[it.type] || ''}</td></tr>`
).join('');
return `<h2>${esc(u.name)}</h2>
<table><tr><th>Item</th><th>Type</th></tr>${items || '<tr><td colspan="2">No items</td></tr>'}</table>`;
}).join('');
const win = window.open('', '_blank');
win.document.write(`<!DOCTYPE html><html><head><title>${esc(course.name)}</title>
<style>
body { font-family: Georgia, serif; max-width: 700px; margin: 2rem auto; color: #222; }
h1 { font-size: 1.5rem; border-bottom: 3px solid #222; padding-bottom: .4rem; }
h2 { font-size: 1.1rem; margin: 1.5rem 0 .4rem; color: #444; }
table { width: 100%; border-collapse: collapse; margin-bottom: .5rem; }
td, th { border: 1px solid #bbb; padding: .35rem .6rem; text-align: left; font-size: .9rem; }
th { background: #f0f0f0; }
</style></head><body>
<h1>${esc(course.name)}${course.subject ? ` <small style="font-size:.8em;color:#666">— ${esc(course.subject)}</small>` : ''}</h1>
${unitBlocks || '<p>No units in this course.</p>'}
<script>window.print()<\/script></body></html>`);
win.document.close();
}
// ============================================================
// Grading queue
// ============================================================
+15
View File
@@ -370,10 +370,25 @@ button.tiny { font-size: 0.75rem; padding: 0.15rem 0.5rem; }
/* ---------- planner ---------- */
.week-label { font-weight: 700; }
/* planner week view */
.planner-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0.75rem; margin-top: 1rem; }
.planner-day { background: var(--card); border-radius: 14px; padding: 0.85rem; box-shadow: 0 2px 8px rgba(45, 49, 66, 0.06); }
.planner-day h3 { font-size: 0.95rem; margin-bottom: 0.5rem; }
.planner-day h3 small { display: block; font-weight: 400; color: var(--muted); }
/* planner month view */
.planner-grid.planner-month { display: block; }
.planner-month-header { display: grid; grid-template-columns: repeat(5, 1fr); gap: 3px; margin-bottom: 3px; }
.planner-month-header div { text-align: center; font-size: 0.75rem; font-weight: 700; color: var(--muted); padding: 0.2rem 0; }
.planner-month-row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 3px; margin-bottom: 3px; }
.planner-day-compact { border-radius: 8px; padding: 0.5rem; min-height: 80px; }
.planner-day-compact h3 { font-size: 0.75rem; margin-bottom: 0.3rem; }
.planner-day-compact .planner-task { font-size: 0.72rem; padding: 0.2rem 0.4rem; }
.planner-day-compact .planner-task span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 90%; }
.planner-day-compact .secondary.small { font-size: 0.7rem; padding: 0.2rem 0.4rem; margin-top: 0.2rem; }
/* view toggle active state */
.secondary.active-view { background: var(--accent); color: #fff; }
.planner-tasks { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: 0.5rem; min-height: 1.5rem; }
.planner-task {
display: flex; justify-content: space-between; align-items: center; gap: 0.4rem;
+17
View File
@@ -1108,6 +1108,23 @@ app.get('/api/schedule-week/:studentId', requirePin, (req, res) => {
res.json({ tasks });
});
app.get('/api/schedule-range/:studentId', requirePin, (req, res) => {
const start = isDateStr(req.query.start) ? req.query.start : null;
const end = isDateStr(req.query.end) ? req.query.end : null;
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, c.name AS courseName
FROM schedule sc
LEFT JOIN items i ON i.id = sc.item_id
LEFT JOIN units u ON u.id = i.unit_id
LEFT JOIN courses c ON c.id = u.course_id
WHERE sc.student_id = ? AND sc.date BETWEEN ? AND ?
ORDER BY sc.date, sc.sort, sc.id
`).all(req.params.studentId, start, end);
res.json({ tasks });
});
// Printable weekly report: schedule completion + any graded work that week
app.get('/api/week-report/:studentId', requirePin, (req, res) => {
const start = isDateStr(req.query.start) ? req.query.start : null;