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 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-24 10:19:29 -04:00
1 parent 4ad11ea4ee
commit 44b481e177
4 files changed
+100

No files matched your search

+23
View File
@@ -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() {
+59
View File
@@ -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! <span class="msg-notif-tap">Tap to read</span>';
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');
+13
View File
@@ -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 ---------- */
+5
View File
@@ -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