From 44b481e17717949b93b07ca60bf205b3ec2236fe Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:19:20 -0400 Subject: [PATCH] Add in-app and OS notifications for new messages Polls every 30s for unread messages on both kid and admin sides. Kid app shows a tappable in-page toast and fires a browser OS notification when a parent message arrives while the tab is open. Admin app shows a toast and OS notification when a student replies. Clicking the OS notification focuses the tab and opens the messages view. Co-Authored-By: Claude Sonnet 4.6 --- public/admin.js | 23 ++++++++++++++++++ public/app.js | 59 +++++++++++++++++++++++++++++++++++++++++++++++ public/styles.css | 13 +++++++++++ server.js | 5 ++++ 4 files changed, 100 insertions(+) diff --git a/public/admin.js b/public/admin.js index 9e15bf3..6aca485 100644 --- a/public/admin.js +++ b/public/admin.js @@ -72,13 +72,21 @@ async function loadAppSettings() { } catch {} } +function fireOsNotif(title, body, onClick) { + if (!('Notification' in window) || Notification.permission !== 'granted') return; + const n = new Notification(title, { body, icon: '/favicon.ico' }); + if (onClick) n.onclick = () => { window.focus(); onClick(); n.close(); }; +} + async function unlock() { await loadAppSettings(); $('#pin-gate').hidden = true; $('#console').hidden = false; showPanel('kids'); + if ('Notification' in window && Notification.permission === 'default') Notification.requestPermission(); loadGradingBadge(); loadMessagesBadge(); + startAdminMsgPoll(); } async function loadGradingBadge() { @@ -1953,6 +1961,8 @@ $('#grading-student-filter').addEventListener('change', loadGrading); // Messages // ============================================================ +let _adminLastMsgCount = -1; + async function loadMessagesBadge() { try { const { count } = await api('/api/admin/messages/count'); @@ -1967,9 +1977,22 @@ async function loadMessagesBadge() { badge?.remove(); pill.classList.remove('grading-pending'); } + if (_adminLastMsgCount >= 0 && count > _adminLastMsgCount) { + msg(`💬 New message from a student (${count} unread)`, 'success'); + fireOsNotif('💬 New student message', `${count} unread message${count !== 1 ? 's' : ''} waiting.`, + () => document.querySelector('.nav-pill[data-panel="messages"]')?.click()); + } + _adminLastMsgCount = count; } catch { /* non-critical */ } } +let _adminMsgPollInterval = null; + +function startAdminMsgPoll() { + clearInterval(_adminMsgPollInterval); + _adminMsgPollInterval = setInterval(loadMessagesBadge, 30000); +} + let activeMessageStudentId = null; async function loadMessages() { diff --git a/public/app.js b/public/app.js index 802926e..5d225dd 100644 --- a/public/app.js +++ b/public/app.js @@ -236,6 +236,7 @@ function resetTheme() { let currentStudent = null; async function loadHome() { + stopMsgPoll(); resetTheme(); const hour = new Date().getHours(); const greeting = hour < 12 ? 'Good morning! ☀️' @@ -369,6 +370,7 @@ async function loadInbox() { markAllSeen(sid, items.map((r) => r.submissionId)); updateInboxBadge(sid, 0); + _lastParentMsgCount = 0; renderMsgThread(msgs); } @@ -427,6 +429,61 @@ async function refreshInboxBadge(studentId) { } catch { /* non-critical */ } } +let _msgPollInterval = null; +let _lastParentMsgCount = -1; + +function showMsgNotifToast() { + const existing = document.getElementById('msg-notif-toast'); + if (existing) existing.remove(); + const toast = document.createElement('div'); + toast.id = 'msg-notif-toast'; + toast.className = 'msg-notif-toast'; + toast.innerHTML = '💬 New message from your parent! Tap to read'; + toast.addEventListener('click', () => { toast.remove(); switchTab('inbox'); }); + document.body.appendChild(toast); + setTimeout(() => toast.classList.add('msg-notif-toast-show'), 10); + setTimeout(() => { + toast.classList.remove('msg-notif-toast-show'); + setTimeout(() => toast.remove(), 400); + }, 6000); +} + +function requestNotifPermission() { + if ('Notification' in window && Notification.permission === 'default') { + Notification.requestPermission(); + } +} + +function fireOsNotif(title, body, onClick) { + if (!('Notification' in window) || Notification.permission !== 'granted') return; + const n = new Notification(title, { body, icon: '/favicon.ico' }); + if (onClick) n.onclick = () => { window.focus(); onClick(); n.close(); }; +} + +function startMsgPoll(studentId) { + clearInterval(_msgPollInterval); + _lastParentMsgCount = -1; + _msgPollInterval = setInterval(async () => { + try { + const { count } = await api(`/api/students/${studentId}/messages/count`); + if (_lastParentMsgCount >= 0 && count > _lastParentMsgCount) { + fireOsNotif('💬 New message!', 'Your parent sent you a message.', () => switchTab('inbox')); + if (currentTab !== 'inbox') { + showMsgNotifToast(); + updateInboxBadge(studentId, count); + } + } + _lastParentMsgCount = count; + } catch { /* non-critical */ } + }, 30000); +} + +function stopMsgPoll() { + clearInterval(_msgPollInterval); + _msgPollInterval = null; + _lastParentMsgCount = -1; +} + async function openKid(id, tab = 'today', welcome = false) { const [state, agenda, courses, { tests: recentTests }] = await Promise.all([ api(`/api/state/${id}`), @@ -435,6 +492,8 @@ async function openKid(id, tab = 'today', welcome = false) { api(`/api/students/${id}/tests`), ]); refreshInboxBadge(id); + requestNotifPermission(); + startMsgPoll(id); currentStudent = state.student; youngLearnerMode = !!currentStudent.young_learner; applyTheme(currentStudent.theme || 'blue'); diff --git a/public/styles.css b/public/styles.css index dd5ae4c..d7bb9b7 100644 --- a/public/styles.css +++ b/public/styles.css @@ -1388,6 +1388,19 @@ body.bg-bubbles { } #toast.toast-error { background: var(--bad); } #toast.toast-out { opacity: 0; transform: translateY(0.5rem); } + +/* ---------- message notification toast (kid app) ---------- */ +.msg-notif-toast { + position: fixed; top: 1rem; left: 50%; transform: translateX(-50%) translateY(-120%); + z-index: 9999; background: var(--accent); color: #fff; + padding: 0.75rem 1.4rem; border-radius: 14px; + font-weight: 700; font-size: 1rem; text-align: center; + box-shadow: 0 6px 24px rgba(0,0,0,0.22); + cursor: pointer; transition: transform 0.35s cubic-bezier(0.34,1.56,0.64,1), opacity 0.35s; + opacity: 0; +} +.msg-notif-toast.msg-notif-toast-show { transform: translateX(-50%) translateY(0); opacity: 1; } +.msg-notif-tap { font-size: 0.82rem; font-weight: 500; opacity: 0.85; margin-left: 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 ---------- */ diff --git a/server.js b/server.js index 3adb905..d5f2565 100644 --- a/server.js +++ b/server.js @@ -1770,6 +1770,11 @@ app.put('/api/submissions/:id/grade', requirePin, (req, res) => { // ---------- messages ---------- +app.get('/api/students/:id/messages/count', (req, res) => { + const { n } = db.prepare(`SELECT COUNT(*) AS n FROM messages WHERE student_id = ? AND sender = 'parent' AND read_at IS NULL`).get(req.params.id); + res.json({ count: n }); +}); + app.get('/api/students/:id/messages', (req, res) => { const msgs = db.prepare(`SELECT id, sender, body, sent_at, read_at FROM messages WHERE student_id = ? ORDER BY sent_at`).all(req.params.id); // Mark parent messages as read when student loads the thread