From 7580257ba02016deb7da70069f76b5f73b436e7d Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:25:36 -0400 Subject: [PATCH] Replace planner queue + inline forms with searchable add-task modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- public/admin.html | 19 ++++- public/admin.js | 181 ++++++++++++++++++++++++++++------------------ public/styles.css | 23 ++++++ 3 files changed, 151 insertions(+), 72 deletions(-) diff --git a/public/admin.html b/public/admin.html index d7a3ddd..f0ecf29 100644 --- a/public/admin.html +++ b/public/admin.html @@ -336,7 +336,7 @@ - +
@@ -546,6 +546,23 @@ + + + diff --git a/public/admin.js b/public/admin.js index f200c14..272103f 100644 --- a/public/admin.js +++ b/public/admin.js @@ -1168,7 +1168,6 @@ function addDays(dateStr, n) { } let plannerWeekStart = null; // initialized lazily after settings load -let plannerQueue = []; let plannerStudentId = null; let plannerCoursesCache = []; let plannerMode = 'week'; // 'week' | 'month' @@ -1190,6 +1189,7 @@ function monthLabel(dateStr) { async function loadPlannerPanel() { if (!plannerWeekStart) plannerWeekStart = weekStartOf(new Date()); + allCourseItems = []; const [students, courses] = await Promise.all([api('/api/students'), api('/api/admin/courses')]); if (students.length === 0) { $('#planner-grid').innerHTML = `

Add a kid first.

`; @@ -1201,6 +1201,7 @@ async function loadPlannerPanel() { $('#auto-course').innerHTML = courses.map((c) => ``).join(''); if (!$('#auto-start-date').value) $('#auto-start-date').value = plannerWeekStart; await renderPlanner(); + loadAllCourseItems(); // pre-fetch in background, no need to await } $('#auto-schedule-btn').addEventListener('click', async () => { @@ -1218,8 +1219,7 @@ $('#auto-schedule-btn').addEventListener('click', async () => { }); $('#planner-student').addEventListener('change', () => { - plannerQueue = []; - updatePlannerSaveBtn(); + allCourseItems = []; plannerStudentId = Number($('#planner-student').value); renderPlanner(); }); @@ -1312,7 +1312,7 @@ function attachPlannerEvents() { }) ); 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 = []) { `).join('') || (!testBadges ? `

โ€”

` : '')} - `; } @@ -1383,81 +1382,121 @@ async function renderPlanner() { attachPlannerEvents(); } -function toggleAddTaskForm(date) { - const box = document.querySelector(`[data-form-for="${date}"]`); - if (!box.hidden) { box.hidden = true; return; } - const itemOptions = plannerCoursesCache - .map((c) => ``) - .join(''); - box.hidden = false; - box.innerHTML = ` - -
or
- - `; +// ---------- planner add-task modal ---------- - const courseSelect = box.querySelector('.task-course-select'); - (async () => { - 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); - } +let allCourseItems = []; // {id, type, title, courseName} +let plannerAddDate = null; + +async function loadAllCourseItems() { + const courses = await api(`/api/courses/mine/${plannerStudentId}`); + const details = await Promise.all(courses.map((c) => api(`/api/admin/courses/${c.id}`))); + allCourseItems = []; + 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 }); } - courseSelect.appendChild(group); } - })(); + } +} - box.querySelector('.task-save-btn').addEventListener('click', () => { - const itemId = courseSelect.value; - const title = box.querySelector('.task-offline-input').value.trim(); - if (!itemId && !title) return; - const label = itemId - ? courseSelect.options[courseSelect.selectedIndex].textContent - : `๐Ÿ“Œ ${title}`; - plannerQueue.push({ date, itemId: itemId || null, title, label }); - 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) => + `` + ).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() { - const btn = $('#planner-save-all'); - const n = plannerQueue.length; - btn.hidden = n === 0; - btn.textContent = `๐Ÿ’พ Save ${n} item${n === 1 ? '' : 's'}`; +function renderItemList(query) { + const q = query.toLowerCase(); + const filtered = q + ? allCourseItems.filter((it) => it.title.toLowerCase().includes(q) || it.courseName.toLowerCase().includes(q)) + : allCourseItems; + + if (!filtered.length) { + $('#planner-item-list').innerHTML = '

No items match.

'; + 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]) => + `
${esc(course)}
` + + items.map((it) => + `
+ ${TYPE_ICON[it.type] || '๐Ÿ“„'} + ${esc(it.title)} + ${TYPE_LABEL[it.type] || ''} +
` + ).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 () => { - const count = plannerQueue.length; - if (!count) return; - await Promise.all(plannerQueue.map((item) => - api('/api/schedule', { - method: 'POST', - body: { studentId: plannerStudentId, date: item.date, itemId: item.itemId, title: item.title }, - }) - )); - plannerQueue = []; - updatePlannerSaveBtn(); - msg(`${count} item${count === 1 ? '' : 's'} added to planner.`); - renderPlanner(); +async function scheduleItem(itemId, title, el) { + if (!plannerAddDate) return; + if (el) { + el.classList.add('added'); + el.innerHTML = 'โœ“Added'; + el.style.pointerEvents = 'none'; + } + await api('/api/schedule', { + method: 'POST', + body: { studentId: plannerStudentId, date: plannerAddDate, itemId: itemId || null, title: title || null }, + }); + if (title) { + msg('Added to planner.'); + $('#planner-offline-input').value = ''; + } + 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) { diff --git a/public/styles.css b/public/styles.css index aa6fbb4..8aa8a72 100644 --- a/public/styles.css +++ b/public/styles.css @@ -1127,6 +1127,29 @@ body.bg-bubbles { #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 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 ---------- */ .cw-layout { display: flex; gap: 1.5rem; flex-wrap: wrap; align-items: flex-start; margin-top: 1rem; } .cw-grid-wrap { overflow: auto; }