Replace planner queue + inline forms with searchable add-task modal

- Single "Add task" button in the planner header opens a modal
- Day tabs (Mon–Fri with dates) let you switch the target day without closing
- Live search filters all course items across all courses instantly
- Items pre-fetched in background when planner loads — no per-click delay
- Clicking an item schedules it immediately (no separate Save step)
- Column "+ Add" buttons still work and open the modal pre-selected to that day
- Offline task field at the bottom for non-course entries
- Backdrop click or ✕ closes modal and re-renders the planner

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-04 14:25:36 -04:00
1 parent 6b7b77829e
commit 7580257ba0
3 files changed
+151 -72

No files matched your search

+18 -1
View File
@@ -336,7 +336,7 @@
<button id="planner-next" class="secondary">Next →</button> <button id="planner-next" class="secondary">Next →</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-save-all" hidden>💾 Save 0 items</button> <button id="planner-add-btn">+ Add task</button>
</div> </div>
<details id="auto-schedule-details" class="admin-card" style="margin-bottom:1rem"> <details id="auto-schedule-details" class="admin-card" style="margin-bottom:1rem">
@@ -546,6 +546,23 @@
<div id="toast" role="status" aria-live="polite" hidden></div> <div id="toast" role="status" aria-live="polite" hidden></div>
<!-- Planner add-task modal -->
<div id="planner-add-modal" class="modal" hidden>
<div class="modal-box planner-add-box">
<div class="planner-modal-header">
<h3>Add to planner</h3>
<button id="planner-modal-close" class="secondary small">✕</button>
</div>
<div id="planner-day-tabs" class="day-tabs"></div>
<input id="planner-item-search" type="search" class="planner-item-search" placeholder="Search course items…" autocomplete="off">
<div id="planner-item-list" class="item-list"></div>
<div class="planner-offline-row">
<input id="planner-offline-input" placeholder="Or type an offline task…">
<button id="planner-offline-add">Add</button>
</div>
</div>
</div>
<script src="admin.js"></script> <script src="admin.js"></script>
</body> </body>
</html> </html>
+110 -71
View File
@@ -1168,7 +1168,6 @@ function addDays(dateStr, n) {
} }
let plannerWeekStart = null; // initialized lazily after settings load let plannerWeekStart = null; // initialized lazily after settings load
let plannerQueue = [];
let plannerStudentId = null; let plannerStudentId = null;
let plannerCoursesCache = []; let plannerCoursesCache = [];
let plannerMode = 'week'; // 'week' | 'month' let plannerMode = 'week'; // 'week' | 'month'
@@ -1190,6 +1189,7 @@ function monthLabel(dateStr) {
async function loadPlannerPanel() { async function loadPlannerPanel() {
if (!plannerWeekStart) plannerWeekStart = weekStartOf(new Date()); if (!plannerWeekStart) plannerWeekStart = weekStartOf(new Date());
allCourseItems = [];
const [students, courses] = await Promise.all([api('/api/students'), api('/api/admin/courses')]); const [students, courses] = await Promise.all([api('/api/students'), api('/api/admin/courses')]);
if (students.length === 0) { if (students.length === 0) {
$('#planner-grid').innerHTML = `<p class="hint">Add a kid first.</p>`; $('#planner-grid').innerHTML = `<p class="hint">Add a kid first.</p>`;
@@ -1201,6 +1201,7 @@ async function loadPlannerPanel() {
$('#auto-course').innerHTML = courses.map((c) => `<option value="${c.id}">${esc(c.name)}</option>`).join(''); $('#auto-course').innerHTML = courses.map((c) => `<option value="${c.id}">${esc(c.name)}</option>`).join('');
if (!$('#auto-start-date').value) $('#auto-start-date').value = plannerWeekStart; if (!$('#auto-start-date').value) $('#auto-start-date').value = plannerWeekStart;
await renderPlanner(); await renderPlanner();
loadAllCourseItems(); // pre-fetch in background, no need to await
} }
$('#auto-schedule-btn').addEventListener('click', async () => { $('#auto-schedule-btn').addEventListener('click', async () => {
@@ -1218,8 +1219,7 @@ $('#auto-schedule-btn').addEventListener('click', async () => {
}); });
$('#planner-student').addEventListener('change', () => { $('#planner-student').addEventListener('change', () => {
plannerQueue = []; allCourseItems = [];
updatePlannerSaveBtn();
plannerStudentId = Number($('#planner-student').value); plannerStudentId = Number($('#planner-student').value);
renderPlanner(); renderPlanner();
}); });
@@ -1312,7 +1312,7 @@ function attachPlannerEvents() {
}) })
); );
document.querySelectorAll('[data-add-task]').forEach((btn) => document.querySelectorAll('[data-add-task]').forEach((btn) =>
btn.addEventListener('click', () => toggleAddTaskForm(btn.dataset.addTask)) btn.addEventListener('click', () => openPlannerAddModal(btn.dataset.addTask))
); );
} }
@@ -1333,7 +1333,6 @@ function renderDayCell(day, tasks, compact = false, spellingTests = []) {
</div>`).join('') || (!testBadges ? `<p class="hint tiny">—</p>` : '')} </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 class="add-task-form" data-form-for="${day.date}" hidden></div>
</div>`; </div>`;
} }
@@ -1383,81 +1382,121 @@ async function renderPlanner() {
attachPlannerEvents(); attachPlannerEvents();
} }
function toggleAddTaskForm(date) { // ---------- planner add-task modal ----------
const box = document.querySelector(`[data-form-for="${date}"]`);
if (!box.hidden) { box.hidden = true; return; }
const itemOptions = plannerCoursesCache
.map((c) => `<optgroup label="${esc(c.name)}"></optgroup>`)
.join('');
box.hidden = false;
box.innerHTML = `
<select class="task-course-select"><option value="">— pick a course item —</option></select>
<div class="or-divider">or</div>
<input class="task-offline-input" placeholder="Offline task, e.g. Read Ch. 3">
<button type="button" class="task-save-btn">Add</button>`;
const courseSelect = box.querySelector('.task-course-select'); let allCourseItems = []; // {id, type, title, courseName}
(async () => { let plannerAddDate = null;
const details = await Promise.all(plannerCoursesCache.map((c) => api(`/api/admin/courses/${c.id}`)));
for (const detail of details) {
const group = document.createElement('optgroup');
group.label = detail.name;
for (const u of detail.units) {
for (const it of u.items) {
const opt = document.createElement('option');
opt.value = it.id;
opt.textContent = `${TYPE_ICON[it.type]} ${it.title}`;
group.appendChild(opt);
}
}
courseSelect.appendChild(group);
}
})();
box.querySelector('.task-save-btn').addEventListener('click', () => { async function loadAllCourseItems() {
const itemId = courseSelect.value; const courses = await api(`/api/courses/mine/${plannerStudentId}`);
const title = box.querySelector('.task-offline-input').value.trim(); const details = await Promise.all(courses.map((c) => api(`/api/admin/courses/${c.id}`)));
if (!itemId && !title) return; allCourseItems = [];
const label = itemId for (const course of details) {
? courseSelect.options[courseSelect.selectedIndex].textContent for (const unit of course.units) {
: `📌 ${title}`; for (const it of unit.items) {
plannerQueue.push({ date, itemId: itemId || null, title, label }); allCourseItems.push({ id: it.id, type: it.type, title: it.title, courseName: course.name });
updatePlannerSaveBtn();
// Show as pending in the day cell without a full re-render
const tasksEl = document.querySelector(`.planner-tasks[data-drop-date="${date}"]`);
if (tasksEl) {
const hint = tasksEl.querySelector('.hint.tiny');
if (hint) hint.remove();
const el = document.createElement('div');
el.className = 'planner-task planner-pending';
el.textContent = label;
tasksEl.appendChild(el);
} }
courseSelect.value = ''; }
box.querySelector('.task-offline-input').value = ''; }
}
function openPlannerAddModal(date) {
plannerAddDate = date || addDays(plannerWeekStart, 0);
renderDayTabs();
renderItemList('');
$('#planner-item-search').value = '';
$('#planner-offline-input').value = '';
$('#planner-add-modal').hidden = false;
setTimeout(() => $('#planner-item-search').focus(), 60);
}
function closePlannerAddModal() {
$('#planner-add-modal').hidden = true;
renderPlanner();
}
function renderDayTabs() {
const days = DAY_NAMES().map((name, i) => ({ name, date: addDays(plannerWeekStart, i) }));
$('#planner-day-tabs').innerHTML = days.map((d) =>
`<button class="day-tab${d.date === plannerAddDate ? ' active' : ''}" data-tab-date="${d.date}">
<span>${d.name.slice(0, 3)}</span>
<small>${d.date.slice(5)}</small>
</button>`
).join('');
$('#planner-day-tabs').querySelectorAll('.day-tab').forEach((btn) => {
btn.addEventListener('click', () => {
plannerAddDate = btn.dataset.tabDate;
document.querySelectorAll('.day-tab').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
});
}); });
} }
function updatePlannerSaveBtn() { function renderItemList(query) {
const btn = $('#planner-save-all'); const q = query.toLowerCase();
const n = plannerQueue.length; const filtered = q
btn.hidden = n === 0; ? allCourseItems.filter((it) => it.title.toLowerCase().includes(q) || it.courseName.toLowerCase().includes(q))
btn.textContent = `💾 Save ${n} item${n === 1 ? '' : 's'}`; : allCourseItems;
if (!filtered.length) {
$('#planner-item-list').innerHTML = '<p class="hint" style="padding:1rem;text-align:center">No items match.</p>';
return;
}
const groups = {};
for (const it of filtered) {
if (!groups[it.courseName]) groups[it.courseName] = [];
groups[it.courseName].push(it);
}
$('#planner-item-list').innerHTML = Object.entries(groups).map(([course, items]) =>
`<div class="item-group-header">${esc(course)}</div>` +
items.map((it) =>
`<div class="item-list-item" tabindex="0" data-item-id="${it.id}">
<span>${TYPE_ICON[it.type] || '📄'}</span>
<span class="item-label">${esc(it.title)}</span>
<span class="item-course">${TYPE_LABEL[it.type] || ''}</span>
</div>`
).join('')
).join('');
$('#planner-item-list').querySelectorAll('.item-list-item').forEach((el) => {
el.addEventListener('click', () => scheduleItem(Number(el.dataset.itemId), null, el));
el.addEventListener('keydown', (e) => { if (e.key === 'Enter') scheduleItem(Number(el.dataset.itemId), null, el); });
});
} }
$('#planner-save-all').addEventListener('click', async () => { async function scheduleItem(itemId, title, el) {
const count = plannerQueue.length; if (!plannerAddDate) return;
if (!count) return; if (el) {
await Promise.all(plannerQueue.map((item) => el.classList.add('added');
api('/api/schedule', { el.innerHTML = '<span>✓</span><span class="item-label">Added</span>';
el.style.pointerEvents = 'none';
}
await api('/api/schedule', {
method: 'POST', method: 'POST',
body: { studentId: plannerStudentId, date: item.date, itemId: item.itemId, title: item.title }, body: { studentId: plannerStudentId, date: plannerAddDate, itemId: itemId || null, title: title || null },
}) });
)); if (title) {
plannerQueue = []; msg('Added to planner.');
updatePlannerSaveBtn(); $('#planner-offline-input').value = '';
msg(`${count} item${count === 1 ? '' : 's'} added to planner.`); }
renderPlanner(); if (el) setTimeout(() => renderItemList($('#planner-item-search').value), 2000);
}
$('#planner-item-search').addEventListener('input', (e) => renderItemList(e.target.value));
$('#planner-modal-close').addEventListener('click', closePlannerAddModal);
$('#planner-add-modal').addEventListener('click', (e) => { if (e.target === $('#planner-add-modal')) closePlannerAddModal(); });
$('#planner-add-btn').addEventListener('click', () => openPlannerAddModal(plannerWeekStart || addDays(weekStartOf(new Date()), 0)));
$('#planner-offline-add').addEventListener('click', () => {
const title = $('#planner-offline-input').value.trim();
if (title) scheduleItem(null, title, null);
});
$('#planner-offline-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') { const t = e.target.value.trim(); if (t) scheduleItem(null, t, null); }
}); });
function renderPrintTaskBlock(t) { function renderPrintTaskBlock(t) {
+23
View File
@@ -1127,6 +1127,29 @@ body.bg-bubbles {
#toast.toast-out { opacity: 0; transform: translateY(0.5rem); } #toast.toast-out { opacity: 0; transform: translateY(0.5rem); }
.planner-pending { border: 2px dashed var(--accent) !important; background: color-mix(in srgb, var(--accent) 10%, var(--card)) !important; color: var(--accent) !important; opacity: 0.85; cursor: default; } .planner-pending { border: 2px dashed var(--accent) !important; background: color-mix(in srgb, var(--accent) 10%, var(--card)) !important; color: var(--accent) !important; opacity: 0.85; cursor: default; }
/* ---------- planner add-task modal ---------- */
.planner-add-box { max-width: 620px; width: 95%; max-height: 88vh; display: flex; flex-direction: column; gap: 0.75rem; padding: 1.25rem; }
.planner-modal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.25rem; }
.planner-modal-header h3 { margin: 0; font-size: 1.1rem; }
.day-tabs { display: flex; gap: 0.35rem; }
.day-tab { flex: 1; padding: 0.45rem 0.3rem; border: 2px solid #d8d2c0; border-radius: 8px; background: var(--bg); cursor: pointer; font-family: inherit; font-size: 0.8rem; text-align: center; line-height: 1.25; }
.day-tab span { display: block; font-weight: 700; }
.day-tab small { color: var(--muted); font-size: 0.7rem; }
.day-tab.active { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, var(--card)); color: var(--accent); }
.day-tab.active small { color: var(--accent); opacity: 0.75; }
.planner-item-search { width: 100%; box-sizing: border-box; padding: 0.55rem 0.75rem; font-size: 0.95rem; font-family: inherit; border: 2px solid #d8d2c0; border-radius: 10px; background: var(--bg); color: var(--ink); }
.planner-item-search:focus { outline: none; border-color: var(--accent); }
.item-list { flex: 1; overflow-y: auto; border: 1.5px solid #e0ddd0; border-radius: 10px; min-height: 180px; max-height: 340px; }
.item-group-header { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); padding: 0.45rem 0.75rem 0.2rem; background: var(--bg); position: sticky; top: 0; }
.item-list-item { padding: 0.5rem 0.75rem; cursor: pointer; border-bottom: 1px solid #ede9dc; display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem; }
.item-list-item:last-child { border-bottom: none; }
.item-list-item:hover, .item-list-item:focus { background: color-mix(in srgb, var(--accent) 10%, var(--card)); outline: none; }
.item-list-item.added { background: var(--good-bg); color: var(--good); cursor: default; }
.item-list-item .item-label { flex: 1; }
.item-list-item .item-course { font-size: 0.72rem; color: var(--muted); margin-left: auto; white-space: nowrap; }
.planner-offline-row { display: flex; gap: 0.5rem; padding-top: 0.75rem; border-top: 1.5px solid #e0ddd0; }
.planner-offline-row input { flex: 1; }
/* ---------- crossword ---------- */ /* ---------- crossword ---------- */
.cw-layout { display: flex; gap: 1.5rem; flex-wrap: wrap; align-items: flex-start; margin-top: 1rem; } .cw-layout { display: flex; gap: 1.5rem; flex-wrap: wrap; align-items: flex-start; margin-top: 1rem; }
.cw-grid-wrap { overflow: auto; } .cw-grid-wrap { overflow: auto; }