@@ -239,8 +281,10 @@
@@ -249,6 +293,7 @@
+
diff --git a/js/api.js b/js/api.js
index 183ed6c..0bc225d 100644
--- a/js/api.js
+++ b/js/api.js
@@ -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 })}`);
},
diff --git a/js/app.js b/js/app.js
index a2d6913..275361f 100644
--- a/js/app.js
+++ b/js/app.js
@@ -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 = `
${escapeHtml(I18N.t('notifications.empty'))}
`;
+ } 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 = `
+
${escapeHtml(entry.icon || '🔔')}
+
+ ${escapeHtml(entry.title || '')}
+ ${escapeHtml(entry.body || '')}
+
+
${time}
+ `;
+ listEl.appendChild(item);
+ });
+ }
+
+ await NotificationStore.markAllRead();
+ refreshNotificationBadge();
+ } catch (err) {
+ listEl.innerHTML = `
⚠ ${escapeHtml(err.message)}
`;
+ }
+ }
+
+ 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();
diff --git a/js/i18n.js b/js/i18n.js
index ffae322..6b5c0e0 100644
--- a/js/i18n.js
+++ b/js/i18n.js
@@ -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',
diff --git a/js/notifications.js b/js/notifications.js
new file mode 100644
index 0000000..c1ae5a6
--- /dev/null
+++ b/js/notifications.js
@@ -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
+//