Navigation an iOS-Tabs angleichen, Benachrichtigungscenter, Einfacher Modus
Phase 2-4 der WebApp-Angleichung an die iOS-App: - Navigation: Favoriten und Störungen sind jetzt eigene Top-Level-Tabs (Desktop-Tableiste + mobile Bottom-Nav) statt nur über das Mehr-Grid bzw. den Stern im Header erreichbar. Neue Header-Icons für Einstellungen (⚙) und Benachrichtigungen (🔔, mit rotem Punkt bei ungelesenen Einträgen). "Mehr" ist jetzt schlanker (In der Nähe, Tickets, Verlauf, App-Info mit Verbindungstest gegen /health/full) und die Sprachauswahl in einen neuen eigenen "Einstellungen"-Tab umgezogen. - Benachrichtigungscenter (js/notifications.js, neu): persistenter Verlauf über IndexedDB, sowohl vom Service Worker (eingehende Web-Push- Nachrichten, auch bei geschlossenem Tab) als auch von der Seite selbst (Störungs-Matches bei Favoriten, Fahrtalarm) beschrieben. Neuer "Benachrichtigungen"-Tab zeigt den Verlauf, Badge am Glocken-Icon zeigt ungelesene Einträge, per postMessage vom Service Worker live aktualisiert. - Einfacher Modus: neuer Toggle im Einstellungen-Tab, persistiert in localStorage, setzt eine .simple-mode-Klasse auf <html> - moderat größere Schrift plus Ausblenden von Verkehrsmittel-Filter/Fahrrad-Option/ "Zwischenhalt hinzufügen" im Routenplaner, analog zur iOS-App (bewusst keine Accessibility-Schriftgrößenstufe, die ist für Screenreader gedacht). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01De7H8hR1Q9DdgyuS2ukN6y
This commit is contained in:
8 files changed
+455
-12
No files matched your search
@@ -154,6 +154,13 @@ const API = (() => {
|
||||
return request('/fares');
|
||||
},
|
||||
|
||||
// Einfacher Verbindungstest gegen den Health-Dashboard-Endpoint, analog
|
||||
// zu APIClient.checkHealth() in der iOS-App - prüft nur, ob das Backend
|
||||
// erreichbar ist (Reverse-Proxy/DNS/VPS korrekt konfiguriert).
|
||||
checkHealth() {
|
||||
return request('/health/full');
|
||||
},
|
||||
|
||||
getNearbyDisruptions({ lat, lon, distance, stops }) {
|
||||
return request(`/disruptions/nearby?${qs({ lat, lon, distance, stops })}`);
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ const App = (() => {
|
||||
const SEEN_DISRUPTIONS_KEY = 'oepnv-navi:db-seen-disruptions';
|
||||
const WHEELCHAIR_KEY = 'oepnv-navi:db-wheelchair';
|
||||
const BIKE_KEY = 'oepnv-navi:db-bike';
|
||||
const SIMPLE_MODE_KEY = 'oepnv-navi:db-simple-mode';
|
||||
const FAVORITES_POLL_MS = 30000;
|
||||
const MAX_VIAS = 3;
|
||||
const VIA_WAIT_OPTIONS = [0, 5, 10, 15, 20, 30, 45, 60];
|
||||
@@ -209,11 +210,13 @@ const App = (() => {
|
||||
function notifyNewDisruption(favName, disruption) {
|
||||
const message = `${favName}: ${disruption.summary || 'Neue Störung'}`;
|
||||
showToast(message, 'warning');
|
||||
const body = disruption.summary || disruption.text || 'Es liegt eine neue Störungsmeldung vor.';
|
||||
recordNotification({ title: 'Störung bei ' + favName, body, icon: '⚠' });
|
||||
|
||||
if (window.Notification && Notification.permission === 'granted') {
|
||||
try {
|
||||
new Notification('Störung bei ' + favName, {
|
||||
body: disruption.summary || disruption.text || 'Es liegt eine neue Störungsmeldung vor.',
|
||||
body,
|
||||
tag: `oepnv-navi-disruption-${favName}-${disruptionKey(disruption)}`,
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -222,6 +225,137 @@ const App = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Benachrichtigungscenter ----------
|
||||
//
|
||||
// Persistiert lokal ausgelöste Benachrichtigungen (Störungen, Fahrtalarm)
|
||||
// im selben IndexedDB-Store wie eingehende Web-Push-Nachrichten (siehe
|
||||
// sw.js + js/notifications.js), damit der "Benachrichtigungen"-Tab wirklich
|
||||
// alles zeigt, was aktuell eine Meldung auslöst - analog zum Glocken-Icon
|
||||
// in der iOS-App.
|
||||
function recordNotification({ title, body, icon }) {
|
||||
if (!window.NotificationStore) return;
|
||||
NotificationStore.add({ title, body, icon }).then(refreshNotificationBadge).catch(() => {});
|
||||
}
|
||||
|
||||
async function refreshNotificationBadge() {
|
||||
const badge = document.getElementById('notifications-badge');
|
||||
if (!badge || !window.NotificationStore) return;
|
||||
try {
|
||||
const count = await NotificationStore.unreadCount();
|
||||
badge.hidden = count === 0;
|
||||
} catch (_) {
|
||||
// IndexedDB evtl. nicht verfügbar (z.B. privater Modus) - Badge bleibt einfach versteckt
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNotificationsPanel() {
|
||||
const listEl = document.getElementById('notifications-list');
|
||||
if (!listEl || !window.NotificationStore) return;
|
||||
|
||||
try {
|
||||
const entries = await NotificationStore.list();
|
||||
listEl.innerHTML = '';
|
||||
|
||||
if (entries.length === 0) {
|
||||
listEl.innerHTML = `<div class="board-empty-state">${escapeHtml(I18N.t('notifications.empty'))}</div>`;
|
||||
} else {
|
||||
entries.forEach((entry) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = `notification-item${entry.read ? '' : ' notification-item--unread'}`;
|
||||
item.setAttribute('role', 'listitem');
|
||||
const time = new Date(entry.timestamp).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
item.innerHTML = `
|
||||
<span class="notification-item__icon" aria-hidden="true">${escapeHtml(entry.icon || '🔔')}</span>
|
||||
<div class="notification-item__body">
|
||||
<strong>${escapeHtml(entry.title || '')}</strong>
|
||||
<span>${escapeHtml(entry.body || '')}</span>
|
||||
</div>
|
||||
<span class="notification-item__time">${time}</span>
|
||||
`;
|
||||
listEl.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
await NotificationStore.markAllRead();
|
||||
refreshNotificationBadge();
|
||||
} catch (err) {
|
||||
listEl.innerHTML = `<div class="board-error">⚠ ${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function initNotificationsPanel() {
|
||||
const clearBtn = document.getElementById('notifications-clear-btn');
|
||||
if (clearBtn && window.NotificationStore) {
|
||||
clearBtn.addEventListener('click', async () => {
|
||||
await NotificationStore.clear();
|
||||
loadNotificationsPanel();
|
||||
});
|
||||
}
|
||||
|
||||
// Der Service Worker meldet per postMessage, sobald eine Push-
|
||||
// Benachrichtigung eintraf (siehe sw.js) - Badge sofort aktualisieren,
|
||||
// auch während die App gerade offen ist.
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'oepnv-navi:notification-added') {
|
||||
refreshNotificationBadge();
|
||||
if (document.querySelector('[data-tab-panel="notifications"]:not([hidden])')) {
|
||||
loadNotificationsPanel();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
refreshNotificationBadge();
|
||||
}
|
||||
|
||||
// ---------- Einstellungen ----------
|
||||
|
||||
// Einfacher Modus: moderat größere Schrift + weniger Optionen auf einen
|
||||
// Blick (Verkehrsmittel-Filter/Fahrrad-Option im Routenplaner ausgeblendet),
|
||||
// analog zum "Einfachen Modus" in der iOS-App - bewusst NICHT die
|
||||
// Accessibility-Schriftgrößen-Stufe, die ist für Screenreader-Nutzung
|
||||
// gedacht, nicht zum "einfacher gestalten".
|
||||
function applySimpleMode(enabled) {
|
||||
document.documentElement.classList.toggle('simple-mode', enabled);
|
||||
}
|
||||
|
||||
function initSettingsPanel() {
|
||||
const toggle = document.getElementById('settings-simple-mode');
|
||||
if (!toggle) return;
|
||||
const enabled = localStorage.getItem(SIMPLE_MODE_KEY) === 'true';
|
||||
toggle.checked = enabled;
|
||||
applySimpleMode(enabled);
|
||||
toggle.addEventListener('change', () => {
|
||||
localStorage.setItem(SIMPLE_MODE_KEY, toggle.checked ? 'true' : 'false');
|
||||
applySimpleMode(toggle.checked);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- "Mehr": Verbindungstest ----------
|
||||
|
||||
function initMorePanel() {
|
||||
const btn = document.getElementById('connection-test-btn');
|
||||
const statusEl = document.getElementById('connection-status');
|
||||
if (!btn || !statusEl) return;
|
||||
|
||||
btn.addEventListener('click', async () => {
|
||||
statusEl.textContent = '…';
|
||||
statusEl.className = 'connection-status';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await API.checkHealth();
|
||||
statusEl.textContent = I18N.t('more.connectionOk');
|
||||
statusEl.classList.add('connection-status--ok');
|
||||
} catch (err) {
|
||||
statusEl.textContent = I18N.t('more.connectionError');
|
||||
statusEl.classList.add('connection-status--error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderFavoriteDisruption(card, favName, disruptions, seenMap, favId) {
|
||||
const el = card.querySelector('[data-disruption]');
|
||||
if (!el) return;
|
||||
@@ -1003,6 +1137,10 @@ const App = (() => {
|
||||
if (tabName === 'fares' && !faresLoadedOnce) {
|
||||
loadFares();
|
||||
}
|
||||
|
||||
if (tabName === 'notifications') {
|
||||
loadNotificationsPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function initNearby() {
|
||||
@@ -1766,6 +1904,9 @@ const App = (() => {
|
||||
initNearby();
|
||||
initNearbyDisruptions();
|
||||
initNotifications();
|
||||
initNotificationsPanel();
|
||||
initSettingsPanel();
|
||||
initMorePanel();
|
||||
TripAlarm.load();
|
||||
renderFavoriteChips();
|
||||
renderRecentSearches();
|
||||
|
||||
+38
@@ -23,6 +23,8 @@ const I18N = (() => {
|
||||
'nav.route': 'Route',
|
||||
'nav.map': 'Karte',
|
||||
'nav.more': 'Mehr',
|
||||
'nav.settings': 'Einstellungen',
|
||||
'nav.notifications': 'Benachrichtigungen',
|
||||
'common.optional': 'OPTIONAL',
|
||||
'common.showAll': 'Alle anzeigen',
|
||||
'common.live': 'LIVE',
|
||||
@@ -124,10 +126,27 @@ const I18N = (() => {
|
||||
'more.nearbyText': 'Haltestellen in deiner Umgebung',
|
||||
'more.disruptionsText': 'Ortsbezogen & netzweit',
|
||||
'more.faresText': 'Preise & wo kaufen',
|
||||
'more.notificationsText': 'Verlauf & Push-Status',
|
||||
'more.settingsText': 'Sprache, Anzeige & mehr',
|
||||
'more.historyEyebrow': 'VERLAUF',
|
||||
'more.historyTitle': 'Zuletzt gesucht',
|
||||
'more.langEyebrow': 'SPRACHE / LANGUAGE',
|
||||
'more.langTitle': 'Sprache',
|
||||
'more.aboutEyebrow': 'ÜBER',
|
||||
'more.aboutTitle': 'App-Info',
|
||||
'more.backend': 'Backend',
|
||||
'more.testConnection': 'Verbindung testen',
|
||||
'more.connectionOk': 'Erreichbar',
|
||||
'more.connectionError': 'Nicht erreichbar',
|
||||
'notifications.eyebrow': 'VERLAUF',
|
||||
'notifications.title': 'Benachrichtigungen',
|
||||
'notifications.text': 'Alles, was zuletzt eine Push- oder In-App-Benachrichtigung ausgelöst hat.',
|
||||
'notifications.empty': 'Noch keine Benachrichtigungen.',
|
||||
'settings.eyebrow': 'EINSTELLUNGEN',
|
||||
'settings.text': 'Anzeige, Sprache und Benachrichtigungen anpassen.',
|
||||
'settings.displayTitle': 'Anzeige',
|
||||
'settings.simpleModeTitle': 'Einfacher Modus',
|
||||
'settings.simpleModeText': 'Größere Schrift, weniger Optionen auf einen Blick.',
|
||||
'search.noResults': 'Keine Treffer',
|
||||
'search.recentSearches': 'Zuletzt gesucht',
|
||||
'search.resultsLabel': 'Suchergebnisse',
|
||||
@@ -144,6 +163,8 @@ const I18N = (() => {
|
||||
'nav.route': 'Route',
|
||||
'nav.map': 'Map',
|
||||
'nav.more': 'More',
|
||||
'nav.settings': 'Settings',
|
||||
'nav.notifications': 'Notifications',
|
||||
'common.optional': 'OPTIONAL',
|
||||
'common.showAll': 'Show all',
|
||||
'common.live': 'LIVE',
|
||||
@@ -245,10 +266,27 @@ const I18N = (() => {
|
||||
'more.nearbyText': 'Stops around you',
|
||||
'more.disruptionsText': 'Local & network-wide',
|
||||
'more.faresText': 'Prices & where to buy',
|
||||
'more.notificationsText': 'History & push status',
|
||||
'more.settingsText': 'Language, display & more',
|
||||
'more.historyEyebrow': 'HISTORY',
|
||||
'more.historyTitle': 'Recently searched',
|
||||
'more.langEyebrow': 'SPRACHE / LANGUAGE',
|
||||
'more.langTitle': 'Language',
|
||||
'more.aboutEyebrow': 'ABOUT',
|
||||
'more.aboutTitle': 'App info',
|
||||
'more.backend': 'Backend',
|
||||
'more.testConnection': 'Test connection',
|
||||
'more.connectionOk': 'Reachable',
|
||||
'more.connectionError': 'Unreachable',
|
||||
'notifications.eyebrow': 'HISTORY',
|
||||
'notifications.title': 'Notifications',
|
||||
'notifications.text': 'Everything that recently triggered a push or in-app notification.',
|
||||
'notifications.empty': 'No notifications yet.',
|
||||
'settings.eyebrow': 'SETTINGS',
|
||||
'settings.text': 'Adjust display, language and notifications.',
|
||||
'settings.displayTitle': 'Display',
|
||||
'settings.simpleModeTitle': 'Simple mode',
|
||||
'settings.simpleModeText': 'Larger text, fewer options at a glance.',
|
||||
'search.noResults': 'No results',
|
||||
'search.recentSearches': 'Recently searched',
|
||||
'search.resultsLabel': 'Search results',
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// Persistenter Benachrichtigungsverlauf ("Notification Center"), analog zum
|
||||
// Glocken-Icon in der iOS-App. Läuft als IndexedDB-Store, weil das - anders
|
||||
// als localStorage - sowohl vom offenen Tab als auch vom Service Worker aus
|
||||
// (auch bei geschlossenem Tab, siehe sw.js) beschrieben werden kann. Wird per
|
||||
// <script> in index.html UND per importScripts() in sw.js eingebunden, daher
|
||||
// bewusst keine ES-Module-Syntax.
|
||||
|
||||
const NotificationStore = (() => {
|
||||
const DB_NAME = 'oepnv-navi-notifications';
|
||||
const STORE = 'notifications';
|
||||
const MAX_ENTRIES = 50;
|
||||
|
||||
function openDb() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!('indexedDB' in self)) {
|
||||
reject(new Error('IndexedDB nicht verfügbar'));
|
||||
return;
|
||||
}
|
||||
const req = indexedDB.open(DB_NAME, 1);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(STORE)) {
|
||||
db.createObjectStore(STORE, { keyPath: 'id', autoIncrement: true });
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function add(entry) {
|
||||
const db = await openDb();
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readwrite');
|
||||
tx.objectStore(STORE).add({
|
||||
title: entry.title || '',
|
||||
body: entry.body || '',
|
||||
url: entry.url || null,
|
||||
icon: entry.icon || '🔔',
|
||||
timestamp: entry.timestamp || Date.now(),
|
||||
read: false,
|
||||
});
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
await trim();
|
||||
}
|
||||
|
||||
async function list() {
|
||||
const db = await openDb();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readonly');
|
||||
const req = tx.objectStore(STORE).getAll();
|
||||
req.onsuccess = () => resolve(req.result.sort((a, b) => b.timestamp - a.timestamp));
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function clear() {
|
||||
const db = await openDb();
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readwrite');
|
||||
tx.objectStore(STORE).clear();
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
const db = await openDb();
|
||||
const entries = await list();
|
||||
if (entries.length === 0) return;
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readwrite');
|
||||
const store = tx.objectStore(STORE);
|
||||
entries.forEach((e) => store.put({ ...e, read: true }));
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function trim() {
|
||||
const entries = await list();
|
||||
if (entries.length <= MAX_ENTRIES) return;
|
||||
const db = await openDb();
|
||||
const toRemove = entries.slice(MAX_ENTRIES);
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readwrite');
|
||||
const store = tx.objectStore(STORE);
|
||||
toRemove.forEach((e) => store.delete(e.id));
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function unreadCount() {
|
||||
try {
|
||||
const entries = await list();
|
||||
return entries.filter((e) => !e.read).length;
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
return { add, list, clear, markAllRead, unreadCount };
|
||||
})();
|
||||
|
||||
// In der Seite (nicht im Service-Worker-Kontext) global verfügbar machen.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.NotificationStore = NotificationStore;
|
||||
}
|
||||
@@ -150,6 +150,12 @@ const TripAlarm = (() => {
|
||||
}
|
||||
|
||||
function notify(title, body) {
|
||||
if (window.NotificationStore) {
|
||||
NotificationStore.add({ title, body, icon: '⏰' }).then(() => {
|
||||
const badge = document.getElementById('notifications-badge');
|
||||
if (badge) badge.hidden = false;
|
||||
}).catch(() => {});
|
||||
}
|
||||
if (window.Notification && Notification.permission === 'granted') {
|
||||
try {
|
||||
new Notification(title, { body, tag: 'oepnv-navi-fahrtalarm' });
|
||||
|
||||
Reference in new issue
Block a user