feat: add reminders for tasks and calendar events (closes #13)
- DB migration #8: reminders table (entity_type, entity_id, remind_at, dismissed, created_by) - REST API: GET /pending, GET /?entity, POST /, PATCH /:id/dismiss, DELETE - Client polling module (reminders.js): 60s interval, toast + Browser Notification API - Tasks: enable reminder with custom date/time in edit modal - Calendar: reminder offset selector (at time / 15min / 1h / 1d before) - Bell badge shows pending count; reminders auto-dismiss after 30s or on user action - SW shell cache updated to include reminders.js + reminders.css - 11 new DB tests covering CRUD, pending query, dismiss, upsert, cascade delete, constraints
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Modul: Erinnerungen (Reminders)
|
||||
* Zweck: Clientseitiges Polling für fällige Erinnerungen, Browser-Benachrichtigungen,
|
||||
* In-App-Toasts und Bell-Badge-Aktualisierung.
|
||||
* Abhängigkeiten: /api.js, /i18n.js
|
||||
*/
|
||||
|
||||
import { api } from '/api.js';
|
||||
import { t } from '/i18n.js';
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Konfiguration
|
||||
// --------------------------------------------------------
|
||||
|
||||
const POLL_INTERVAL_MS = 60_000; // 1 Minute
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Zustand
|
||||
// --------------------------------------------------------
|
||||
|
||||
let _pollTimer = null;
|
||||
let _shownIds = new Set(); // bereits angezeigte Reminder-IDs in dieser Session
|
||||
let _isInitialized = false;
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Browser-Benachrichtigungen
|
||||
// --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Aktuellen Benachrichtigungs-Permission-Status zurückgeben.
|
||||
* @returns {'granted'|'denied'|'default'|'unsupported'}
|
||||
*/
|
||||
function notificationStatus() {
|
||||
if (!('Notification' in window)) return 'unsupported';
|
||||
return Notification.permission;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-Benachrichtigung anfordern.
|
||||
* @returns {Promise<'granted'|'denied'|'default'>}
|
||||
*/
|
||||
async function requestPermission() {
|
||||
if (!('Notification' in window)) return 'unsupported';
|
||||
if (Notification.permission === 'granted') return 'granted';
|
||||
return Notification.requestPermission();
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt eine native Browser-Benachrichtigung an.
|
||||
* @param {string} title
|
||||
* @param {string} body
|
||||
*/
|
||||
function showBrowserNotification(title, body) {
|
||||
if (!('Notification' in window) || Notification.permission !== 'granted') return;
|
||||
try {
|
||||
const n = new Notification(title, { body, icon: '/icons/icon-192.png' });
|
||||
setTimeout(() => n.close(), 8000);
|
||||
} catch {
|
||||
// Notification-API kann in bestimmten Kontexten fehlschlagen
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Bell-Badge (Sidebar / Bottom-Nav)
|
||||
// --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Aktualisiert den Badge-Zähler am Bell-Icon in der Navigation.
|
||||
* @param {number} count
|
||||
*/
|
||||
function updateBellBadge(count) {
|
||||
document.querySelectorAll('.reminder-bell-badge').forEach((badge) => {
|
||||
if (count > 0) {
|
||||
badge.textContent = count > 9 ? '9+' : String(count);
|
||||
badge.hidden = false;
|
||||
} else {
|
||||
badge.hidden = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// SVG-Helfer (DOM-API, kein innerHTML)
|
||||
// --------------------------------------------------------
|
||||
|
||||
function createBellSvg() {
|
||||
const NS = 'http://www.w3.org/2000/svg';
|
||||
const svg = document.createElementNS(NS, 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('fill', 'none');
|
||||
svg.setAttribute('stroke', 'currentColor');
|
||||
svg.setAttribute('stroke-width', '2');
|
||||
svg.setAttribute('width', '16');
|
||||
svg.setAttribute('height', '16');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const path1 = document.createElementNS(NS, 'path');
|
||||
path1.setAttribute('d', 'M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9');
|
||||
const path2 = document.createElementNS(NS, 'path');
|
||||
path2.setAttribute('d', 'M13.73 21a2 2 0 0 1-3.46 0');
|
||||
|
||||
svg.appendChild(path1);
|
||||
svg.appendChild(path2);
|
||||
return svg;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Erinnerungen anzeigen
|
||||
// --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Verarbeitet eine Liste fälliger Erinnerungen und zeigt Toast + Browser-Notification.
|
||||
* @param {Array} reminders
|
||||
*/
|
||||
function processReminders(reminders) {
|
||||
const newOnes = reminders.filter((r) => !_shownIds.has(r.id));
|
||||
if (!newOnes.length) return;
|
||||
|
||||
newOnes.forEach((reminder) => {
|
||||
_shownIds.add(reminder.id);
|
||||
showReminderToast(reminder);
|
||||
showBrowserNotification(
|
||||
t('reminders.toastTitle'),
|
||||
reminder.entity_title || ''
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt einen persistenten Toast für eine Erinnerung mit Verwerfen-Button.
|
||||
* @param {{ id: number, entity_title: string }} reminder
|
||||
*/
|
||||
function showReminderToast(reminder) {
|
||||
const container = document.getElementById('toast-container');
|
||||
if (!container) return;
|
||||
|
||||
const existing = container.querySelectorAll('.toast');
|
||||
if (existing.length >= 3) existing[0].remove();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast toast--reminder';
|
||||
toast.setAttribute('role', 'alert');
|
||||
toast.dataset.reminderId = reminder.id;
|
||||
|
||||
const iconWrap = document.createElement('span');
|
||||
iconWrap.className = 'toast__reminder-icon';
|
||||
iconWrap.setAttribute('aria-hidden', 'true');
|
||||
iconWrap.appendChild(createBellSvg());
|
||||
|
||||
const textSpan = document.createElement('span');
|
||||
textSpan.className = 'toast__reminder-text';
|
||||
|
||||
const titleEl = document.createElement('strong');
|
||||
titleEl.textContent = t('reminders.toastTitle');
|
||||
|
||||
const sep = document.createTextNode(': ');
|
||||
|
||||
const bodyEl = document.createElement('span');
|
||||
bodyEl.textContent = reminder.entity_title || '';
|
||||
|
||||
textSpan.appendChild(titleEl);
|
||||
textSpan.appendChild(sep);
|
||||
textSpan.appendChild(bodyEl);
|
||||
|
||||
const dismissBtn = document.createElement('button');
|
||||
dismissBtn.className = 'toast__undo';
|
||||
dismissBtn.textContent = t('reminders.dismiss');
|
||||
dismissBtn.addEventListener('click', () => {
|
||||
dismissReminder(reminder.id);
|
||||
toast.remove();
|
||||
});
|
||||
|
||||
toast.appendChild(iconWrap);
|
||||
toast.appendChild(textSpan);
|
||||
toast.appendChild(dismissBtn);
|
||||
container.appendChild(toast);
|
||||
|
||||
// Reminder-Toasts bleiben 30 Sekunden sichtbar
|
||||
const dismissTimer = setTimeout(() => {
|
||||
toast.classList.add('toast--out');
|
||||
toast.addEventListener('animationend', () => toast.remove(), { once: true });
|
||||
}, 30_000);
|
||||
|
||||
toast.addEventListener('click', (e) => {
|
||||
if (e.target === dismissBtn) return;
|
||||
clearTimeout(dismissTimer);
|
||||
dismissReminder(reminder.id);
|
||||
toast.remove();
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// API-Aktionen
|
||||
// --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Verwirft eine Erinnerung serverseitig.
|
||||
* @param {number} id
|
||||
*/
|
||||
async function dismissReminder(id) {
|
||||
try {
|
||||
await api.patch(`/reminders/${id}/dismiss`, {});
|
||||
_shownIds.delete(id);
|
||||
} catch {
|
||||
// Netzwerkfehler ignorieren
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt fällige Erinnerungen vom Server und verarbeitet sie.
|
||||
*/
|
||||
async function poll() {
|
||||
try {
|
||||
const data = await api.get('/reminders/pending');
|
||||
const reminders = data.data ?? [];
|
||||
updateBellBadge(reminders.length);
|
||||
processReminders(reminders);
|
||||
} catch {
|
||||
// Polling-Fehler ignorieren (kann Offline-Zustand sein)
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Öffentliche API
|
||||
// --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Startet das Reminder-Polling. Idempotent.
|
||||
*/
|
||||
function init() {
|
||||
if (_isInitialized) return;
|
||||
_isInitialized = true;
|
||||
poll();
|
||||
_pollTimer = setInterval(poll, POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stoppt das Polling (z.B. nach Logout).
|
||||
*/
|
||||
function stop() {
|
||||
if (_pollTimer) {
|
||||
clearInterval(_pollTimer);
|
||||
_pollTimer = null;
|
||||
}
|
||||
_isInitialized = false;
|
||||
_shownIds.clear();
|
||||
updateBellBadge(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzwingt sofortigen Poll (z.B. nach Erstellen einer Erinnerung).
|
||||
*/
|
||||
function refresh() {
|
||||
poll();
|
||||
}
|
||||
|
||||
export { init, stop, refresh, requestPermission, notificationStatus };
|
||||
Reference in New Issue
Block a user