e384ae1037
- 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
258 lines
7.2 KiB
JavaScript
258 lines
7.2 KiB
JavaScript
/**
|
|
* 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 };
|