Files
Ulas Kalayci efd4e8c924 feat(a11y): WCAG 2.2 accessibility fixes across four areas
- modal/_validateField: set aria-invalid on invalid inputs so screen readers
  announce field errors; login.js mirrors this for username/password fields
- color pickers (notes, calendar): wrap swatches in role="radiogroup" with
  aria-labelledby, add aria-checked per swatch, localized aria-labels instead
  of hex values, roving tabindex with Arrow/Enter/Space keyboard navigation
- nav badges: badge spans get aria-hidden="true"; nav link aria-label updated
  to include overdue count (tasks) or pending reminder count (reminders)
- router: remove aria-live from <main> (caused full page re-reads on nav);
  add dedicated #route-announcer sr-only region with aria-live=polite +
  aria-atomic, announces page label 50ms after render completes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 00:38:50 +02:00

265 lines
7.5 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) {
const navLabel = count > 0
? t(count === 1 ? 'reminders.pendingBadgeTitle' : 'reminders.pendingBadgeTitlePlural', { count })
: t('nav.reminders');
document.querySelectorAll('[data-route="/reminders"]').forEach((navItem) => {
navItem.setAttribute('aria-label', navLabel);
});
document.querySelectorAll('.reminder-bell-badge').forEach((badge) => {
if (count > 0) {
badge.setAttribute('aria-hidden', 'true');
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 };