const DEFAULT_API_BASE = window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1" ? "http://127.0.0.1:8787" : "/api"; const API_BASE = window.STAYSENSE_API_BASE || DEFAULT_API_BASE; const DEVICE_TOKEN_KEY = "staysense.device_token.v1"; const SETTINGS_KEY = "staysense.settings.v1"; const SCORE_CACHE_KEY = "staysense.score_cache.v1"; const SIGNAL_QUEUE_KEY = "staysense.signal_queue.v1"; const ADMIN_TOKEN_KEY = "staysense.admin_token.v1"; const MAX_CACHE_ITEMS = 50; const latEl = document.getElementById("lat"); const lonEl = document.getElementById("lon"); const searchQueryEl = document.getElementById("search-query"); const searchLocationEl = document.getElementById("search-location"); const searchStatusEl = document.getElementById("search-status"); const searchResultsEl = document.getElementById("search-results"); const mapEl = document.getElementById("map"); const loadScoreEl = document.getElementById("load-score"); const useLocationEl = document.getElementById("use-location"); const scoreEl = document.getElementById("score"); const ampelEl = document.getElementById("ampel"); const reasonsEl = document.getElementById("reasons"); const factorDetailsEl = document.getElementById("factor-details"); const qualityBadgeEl = document.getElementById("quality-badge"); const spotContextEl = document.getElementById("spot-context"); const nightWindowEl = document.getElementById("night-window"); const networkStatusEl = document.getElementById("network-status"); const dataStatusEl = document.getElementById("data-status"); const signalStatusEl = document.getElementById("signal-status"); const queueStatusEl = document.getElementById("queue-status"); const signalsEnabledEl = document.getElementById("signals-enabled"); const legalOutputEl = document.getElementById("legal-output"); const adminSetupEl = document.getElementById("admin-setup"); const adminLoginEl = document.getElementById("admin-login"); const adminContentEl = document.getElementById("admin-content"); const adminStatusEl = document.getElementById("admin-status"); const adminCountsEl = document.getElementById("admin-counts"); const adminEventsEl = document.getElementById("admin-events"); const adminSignalsEl = document.getElementById("admin-signals"); const adminSourcesEl = document.getElementById("admin-sources"); const adminSetupUserEl = document.getElementById("admin-setup-user"); const adminSetupPassEl = document.getElementById("admin-setup-pass"); const adminSetupSubmitEl = document.getElementById("admin-setup-submit"); const adminLoginUserEl = document.getElementById("admin-login-user"); const adminLoginPassEl = document.getElementById("admin-login-pass"); const adminLoginSubmitEl = document.getElementById("admin-login-submit"); const adminLogoutEl = document.getElementById("admin-logout"); const adminRefreshEl = document.getElementById("admin-refresh"); const adminEventCreateEl = document.getElementById("admin-event-create"); const adminEventDeleteEl = document.getElementById("admin-event-delete"); const adminEventIdEl = document.getElementById("admin-event-id"); const adminEventTypeEl = document.getElementById("admin-event-type"); const adminEventRiskEl = document.getElementById("admin-event-risk"); const adminEventLatEl = document.getElementById("admin-event-lat"); const adminEventLonEl = document.getElementById("admin-event-lon"); const adminEventStartEl = document.getElementById("admin-event-start"); const adminEventEndEl = document.getElementById("admin-event-end"); const adminEventSourceEl = document.getElementById("admin-event-source"); let currentSpot = null; let scoreCache = loadJSON(SCORE_CACHE_KEY, []); let signalQueue = loadJSON(SIGNAL_QUEUE_KEY, []); let settings = loadJSON(SETTINGS_KEY, { signalsEnabled: true }); let apiOnline = false; let lastHealthCheckAt = null; let lastHealthLatencyMs = null; let map = null; let mapMarker = null; let searchResults = []; let selectedSearchIndex = -1; let adminToken = localStorage.getItem(ADMIN_TOKEN_KEY) || ""; const deviceToken = ensureDeviceToken(); initialize(); function initialize() { signalsEnabledEl.checked = Boolean(settings.signalsEnabled); renderNetworkStatus(); window.addEventListener("online", onNetworkHint); window.addEventListener("offline", onNetworkHint); signalsEnabledEl.addEventListener("change", () => { settings.signalsEnabled = signalsEnabledEl.checked; saveJSON(SETTINGS_KEY, settings); }); useLocationEl.addEventListener("click", fillLocationFromDevice); loadScoreEl.addEventListener("click", loadScore); searchLocationEl.addEventListener("click", searchLocation); searchQueryEl.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); searchLocation(); } }); latEl.addEventListener("change", () => updateMapFromInputs(14)); lonEl.addEventListener("change", () => updateMapFromInputs(14)); document.querySelectorAll(".signal").forEach((btn) => { btn.addEventListener("click", () => sendSignal(btn.dataset.signal)); }); adminSetupSubmitEl.addEventListener("click", adminBootstrap); adminLoginSubmitEl.addEventListener("click", adminLogin); adminLogoutEl.addEventListener("click", adminLogout); adminRefreshEl.addEventListener("click", loadAdminOverview); adminEventCreateEl.addEventListener("click", saveAdminEvent); adminEventDeleteEl.addEventListener("click", deleteAdminEvent); adminEventsEl.addEventListener("click", onAdminEventListClick); // Pilotwert für Mettmann, falls noch keine Eingabe. if (!latEl.value && !lonEl.value) { latEl.value = "51.2500"; lonEl.value = "6.9730"; } initializeMap(); updateMapFromInputs(); flushSignalQueue(); renderQueueStatus(); checkApiHealth(); setInterval(checkApiHealth, 30000); loadAdminBootstrapStatus(); } function ensureDeviceToken() { let token = localStorage.getItem(DEVICE_TOKEN_KEY); if (!token) { token = crypto.randomUUID(); localStorage.setItem(DEVICE_TOKEN_KEY, token); } return token; } function loadJSON(key, fallback) { try { const raw = localStorage.getItem(key); return raw ? JSON.parse(raw) : fallback; } catch { return fallback; } } function saveJSON(key, value) { localStorage.setItem(key, JSON.stringify(value)); } function renderNetworkStatus() { const checkedAt = lastHealthCheckAt ? toLocal(lastHealthCheckAt) : "-"; const latency = Number.isFinite(lastHealthLatencyMs) ? `${lastHealthLatencyMs}ms` : "-"; networkStatusEl.textContent = `API: ${apiOnline ? "Online" : "Offline"} | letzter Check: ${checkedAt} | Latenz: ${latency}`; } function onNetworkHint() { // Hint event from browser/OS network stack: trigger real check, do not trust onLine flag as truth. flushSignalQueue(); checkApiHealth(); } async function checkApiHealth() { const started = performance.now(); try { const response = await fetch(`${API_BASE}/health`, { cache: "no-store" }); if (!response.ok) { throw new Error("health_failed"); } const payload = await response.json(); apiOnline = true; lastHealthLatencyMs = Math.round(performance.now() - started); lastHealthCheckAt = new Date().toISOString(); renderDataStatusFromHealth(payload && payload.health ? payload.health : null); } catch { apiOnline = false; lastHealthLatencyMs = null; lastHealthCheckAt = new Date().toISOString(); dataStatusEl.textContent = "Datenstand: aktuell nicht abrufbar (API offline)"; } renderNetworkStatus(); } function renderDataStatusFromHealth(health) { if (!health || !health.has_data) { dataStatusEl.textContent = "Datenstand: keine Quellenmetadaten"; return; } const freshness = `freshest ${health.freshest_age_hours}h, stalest ${health.stalest_age_hours}h`; const stale = health.stale_sources && health.stale_sources.length ? `, stale: ${health.stale_sources.join(", ")}` : ""; dataStatusEl.textContent = `Datenstand: ${freshness}${stale}`; } function renderQueueStatus() { queueStatusEl.textContent = `Warteschlange: ${signalQueue.length} ausstehend`; } async function fillLocationFromDevice() { if (!navigator.geolocation) { alert("Geolocation wird auf diesem Gerät nicht unterstützt."); return; } useLocationEl.disabled = true; navigator.geolocation.getCurrentPosition( (position) => { selectedSearchIndex = -1; renderSearchResults(); setCoordinates(position.coords.latitude, position.coords.longitude, { zoom: 16 }); searchStatusEl.textContent = "Aktueller Standort übernommen."; useLocationEl.disabled = false; }, () => { alert("Standort konnte nicht gelesen werden."); useLocationEl.disabled = false; }, { enableHighAccuracy: true, maximumAge: 60000, timeout: 7000 } ); } function initializeMap() { if (!mapEl || typeof L === "undefined") { searchStatusEl.textContent = "Karte konnte nicht geladen werden."; return; } map = L.map(mapEl, { zoomControl: true }).setView([51.2500, 6.9730], 12); L.tileLayer(`${API_BASE}/map/tile/{z}/{x}/{y}.png`, { maxZoom: 19, attribution: '© OpenStreetMap', }).addTo(map); map.on("click", (event) => { selectedSearchIndex = -1; renderSearchResults(); setCoordinates(event.latlng.lat, event.latlng.lng, { fromMap: true }); searchStatusEl.textContent = "Position aus Karte übernommen."; }); } function setCoordinates(lat, lon, options = {}) { const zoom = options.zoom || null; const skipMarkerUpdate = Boolean(options.skipMarkerUpdate); latEl.value = Number(lat).toFixed(6); lonEl.value = Number(lon).toFixed(6); if (!map) { return; } const latLng = [Number(lat), Number(lon)]; if (!skipMarkerUpdate) { if (!mapMarker) { mapMarker = L.marker(latLng, { draggable: true, icon: L.divIcon({ className: "spot-pin", html: "", iconSize: [16, 16], iconAnchor: [8, 8] }), }).addTo(map); mapMarker.on("dragend", () => { const pos = mapMarker.getLatLng(); setCoordinates(pos.lat, pos.lng, { fromMap: true, zoom: map.getZoom(), skipMarkerUpdate: true }); searchStatusEl.textContent = "Position per Pin verschoben."; }); } else { mapMarker.setLatLng(latLng); } } if (Number.isFinite(zoom)) { map.setView(latLng, zoom); } else if (options.fromMap) { map.panTo(latLng); } } function updateMapFromInputs(zoom = null) { const lat = parseCoordinateInput(latEl.value); const lon = parseCoordinateInput(lonEl.value); if (!Number.isFinite(lat) || !Number.isFinite(lon)) { return; } setCoordinates(lat, lon, { zoom }); } async function searchLocation() { const query = searchQueryEl.value.trim(); if (!query) { searchStatusEl.textContent = "Bitte einen Suchbegriff eingeben."; return; } searchLocationEl.disabled = true; searchStatusEl.textContent = "Suche läuft ..."; try { const response = await fetch(`${API_BASE}/geocode/search?q=${encodeURIComponent(query)}`); if (!response.ok) { throw new Error("search_failed"); } const payload = await response.json(); if (!payload.results || !payload.results.length) { searchResults = []; selectedSearchIndex = -1; renderSearchResults(); searchStatusEl.textContent = "Keine Treffer gefunden."; return; } searchResults = payload.results; selectedSearchIndex = 0; renderSearchResults(); const best = searchResults[0]; setCoordinates(best.lat, best.lon, { zoom: 16 }); searchStatusEl.textContent = `Treffer ausgewählt: ${best.display_name}`; } catch { searchResults = []; selectedSearchIndex = -1; renderSearchResults(); searchStatusEl.textContent = "Suche fehlgeschlagen. Bitte später erneut versuchen."; } finally { searchLocationEl.disabled = false; } } async function loadAdminBootstrapStatus() { adminStatusEl.textContent = "Admin-Status wird geladen ..."; try { const response = await fetch(`${API_BASE}/admin/bootstrap/status`, { cache: "no-store" }); if (!response.ok) { throw new Error("bootstrap_status_failed"); } const payload = await response.json(); renderAdminMode(payload.initialized); if (!payload.initialized) { adminStatusEl.textContent = "Erst-Setup erforderlich: Bitte initialen Admin anlegen."; return; } adminStatusEl.textContent = adminToken ? "Admin bereit. Session wird geprüft ..." : "Admin bereit. Bitte anmelden."; if (adminToken) { await loadAdminOverview(); } } catch { renderAdminMode(false); adminStatusEl.textContent = "Admin-Status konnte nicht geladen werden."; } } function renderAdminMode(initialized) { adminSetupEl.classList.toggle("hidden", initialized); adminLoginEl.classList.toggle("hidden", !initialized); if (!initialized) { adminContentEl.classList.add("hidden"); } } function adminHeaders() { return adminToken ? { Authorization: `Bearer ${adminToken}` } : {}; } async function adminBootstrap() { const username = adminSetupUserEl.value.trim(); const password = adminSetupPassEl.value; if (username.length < 3 || password.length < 10) { adminStatusEl.textContent = "Setup fehlgeschlagen: User mind. 3 Zeichen, Passwort mind. 10 Zeichen."; return; } adminSetupSubmitEl.disabled = true; try { const response = await fetch(`${API_BASE}/admin/bootstrap`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); const payload = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(payload.error || "setup_failed"); } adminToken = payload.session.token; localStorage.setItem(ADMIN_TOKEN_KEY, adminToken); adminSetupPassEl.value = ""; adminLoginUserEl.value = username; adminStatusEl.textContent = "Admin wurde angelegt und eingeloggt."; renderAdminMode(true); await loadAdminOverview(); } catch (error) { adminStatusEl.textContent = `Setup fehlgeschlagen: ${String(error.message || "unbekannter Fehler")}`; } finally { adminSetupSubmitEl.disabled = false; } } async function adminLogin() { const username = adminLoginUserEl.value.trim(); const password = adminLoginPassEl.value; if (!username || !password) { adminStatusEl.textContent = "Bitte User und Passwort eingeben."; return; } adminLoginSubmitEl.disabled = true; try { const response = await fetch(`${API_BASE}/admin/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); const payload = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(payload.error || "login_failed"); } adminToken = payload.session.token; localStorage.setItem(ADMIN_TOKEN_KEY, adminToken); adminLoginPassEl.value = ""; adminStatusEl.textContent = "Admin Login erfolgreich."; await loadAdminOverview(); } catch (error) { adminStatusEl.textContent = `Login fehlgeschlagen: ${String(error.message || "unbekannter Fehler")}`; } finally { adminLoginSubmitEl.disabled = false; } } async function adminLogout() { try { await fetch(`${API_BASE}/admin/logout`, { method: "POST", headers: adminHeaders() }); } catch { // ignore } adminToken = ""; localStorage.removeItem(ADMIN_TOKEN_KEY); adminContentEl.classList.add("hidden"); adminStatusEl.textContent = "Abgemeldet."; } function renderAdminList(target, rows, mapper) { target.innerHTML = ""; if (!rows || !rows.length) { target.textContent = "Keine Daten."; return; } rows.forEach((row) => { const div = document.createElement("div"); div.className = "admin-list-item"; if (row && row.id) { div.dataset.eventId = row.id; div.title = "Klick setzt Event-ID"; } div.textContent = mapper(row); target.appendChild(div); }); } async function loadAdminOverview() { if (!adminToken) { adminStatusEl.textContent = "Nicht eingeloggt."; adminContentEl.classList.add("hidden"); return; } try { const response = await fetch(`${API_BASE}/admin/overview`, { headers: adminHeaders() }); const payload = await response.json().catch(() => ({})); if (response.status === 401) { adminToken = ""; localStorage.removeItem(ADMIN_TOKEN_KEY); adminContentEl.classList.add("hidden"); adminStatusEl.textContent = "Session abgelaufen. Bitte erneut anmelden."; return; } if (!response.ok) { throw new Error(payload.error || "admin_overview_failed"); } adminContentEl.classList.remove("hidden"); adminStatusEl.textContent = `Eingeloggt als ${payload.admin_user}.`; adminCountsEl.textContent = `Spots: ${payload.counts.spots} | Signale: ${payload.counts.signals} | Events: ${payload.counts.events} | Quellen: ${payload.counts.data_sources}`; renderAdminList( adminEventsEl, payload.latest_events, (row) => `${row.id} | ${row.event_type} | ${Number(row.lat).toFixed(5)}, ${Number(row.lon).toFixed(5)} | ${row.start_datetime} -> ${row.end_datetime} | risk ${row.risk_modifier} | ${row.source}` ); renderAdminList( adminSignalsEl, payload.latest_signals, (row) => `${row.timestamp} | ${row.signal_type} | spot ${row.spot_id}` ); renderAdminList( adminSourcesEl, payload.data_sources, (row) => `${row.source_name} | ${row.record_count} records | ${row.imported_at} | ${row.notes}` ); } catch (error) { adminStatusEl.textContent = `Admin-Daten konnten nicht geladen werden: ${String(error.message || "unbekannter Fehler")}`; } } function buildAdminEventPayload() { return { event_type: adminEventTypeEl.value, risk_modifier: Number(adminEventRiskEl.value), lat: Number(adminEventLatEl.value), lon: Number(adminEventLonEl.value), start_datetime: adminEventStartEl.value.trim(), end_datetime: adminEventEndEl.value.trim(), source: adminEventSourceEl.value.trim() || "admin_manual", }; } async function saveAdminEvent() { if (!adminToken) { adminStatusEl.textContent = "Bitte zuerst anmelden."; return; } const eventId = adminEventIdEl.value.trim(); const payload = buildAdminEventPayload(); if (!Number.isFinite(payload.lat) || !Number.isFinite(payload.lon)) { adminStatusEl.textContent = "Ungültige Event-Koordinaten."; return; } const method = eventId ? "PUT" : "POST"; const url = eventId ? `${API_BASE}/admin/events/${encodeURIComponent(eventId)}` : `${API_BASE}/admin/events`; adminEventCreateEl.disabled = true; try { const response = await fetch(url, { method, headers: { ...adminHeaders(), "Content-Type": "application/json" }, body: JSON.stringify(payload), }); const body = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(body.error || "event_save_failed"); } adminStatusEl.textContent = eventId ? `Event aktualisiert: ${eventId}` : `Event angelegt: ${body.id}`; if (!eventId && body.id) { adminEventIdEl.value = body.id; } await loadAdminOverview(); } catch (error) { adminStatusEl.textContent = `Event konnte nicht gespeichert werden: ${String(error.message || "unbekannter Fehler")}`; } finally { adminEventCreateEl.disabled = false; } } async function deleteAdminEvent() { if (!adminToken) { adminStatusEl.textContent = "Bitte zuerst anmelden."; return; } const eventId = adminEventIdEl.value.trim(); if (!eventId) { adminStatusEl.textContent = "Bitte Event-ID zum Löschen eingeben."; return; } adminEventDeleteEl.disabled = true; try { const response = await fetch(`${API_BASE}/admin/events/${encodeURIComponent(eventId)}`, { method: "DELETE", headers: adminHeaders(), }); const body = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(body.error || "event_delete_failed"); } adminStatusEl.textContent = `Event gelöscht: ${eventId}`; adminEventIdEl.value = ""; await loadAdminOverview(); } catch (error) { adminStatusEl.textContent = `Event konnte nicht gelöscht werden: ${String(error.message || "unbekannter Fehler")}`; } finally { adminEventDeleteEl.disabled = false; } } function onAdminEventListClick(event) { const row = event.target.closest(".admin-list-item"); if (!row) { return; } if (row.dataset.eventId) { adminEventIdEl.value = row.dataset.eventId; adminStatusEl.textContent = `Event-ID übernommen: ${row.dataset.eventId}`; } } function renderSearchResults() { searchResultsEl.innerHTML = ""; searchResults.forEach((result, index) => { const li = document.createElement("li"); const button = document.createElement("button"); button.type = "button"; button.className = "search-result-btn"; if (index === selectedSearchIndex) { button.classList.add("active"); } button.textContent = result.display_name; button.addEventListener("click", () => { selectedSearchIndex = index; setCoordinates(result.lat, result.lon, { zoom: 16 }); searchStatusEl.textContent = `Treffer ausgewählt: ${result.display_name}`; renderSearchResults(); }); li.appendChild(button); searchResultsEl.appendChild(li); }); } function cacheKey(lat, lon) { return `${Number(lat).toFixed(4)}:${Number(lon).toFixed(4)}`; } function putScoreCache(entry) { scoreCache = [entry, ...scoreCache.filter((it) => it.key !== entry.key)].slice(0, MAX_CACHE_ITEMS); saveJSON(SCORE_CACHE_KEY, scoreCache); } function findCachedScore(lat, lon) { return scoreCache.find((it) => it.key === cacheKey(lat, lon)); } async function loadScore() { const lat = parseCoordinateInput(latEl.value); const lon = parseCoordinateInput(lonEl.value); if (!Number.isFinite(lat) || !Number.isFinite(lon)) { alert("Bitte gültige Koordinaten eingeben (z. B. 51.10893 oder 51,10893)."); return; } // Normalize input display to avoid locale-related parsing issues on repeated requests. latEl.value = lat.toFixed(6); lonEl.value = lon.toFixed(6); loadScoreEl.disabled = true; const at = new Date().toISOString(); try { const response = await fetch(`${API_BASE}/spot/score?lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}&at=${encodeURIComponent(at)}`); if (!response.ok) { const payload = await response.json().catch(() => ({})); signalStatusEl.textContent = `Live-Score fehlgeschlagen: ${payload.error || `HTTP ${response.status}`}.`; throw new Error("api_error"); } const payload = await response.json(); currentSpot = payload; renderScore(payload, false); putScoreCache({ key: cacheKey(lat, lon), fetchedAt: new Date().toISOString(), payload, }); } catch { const cached = findCachedScore(lat, lon); if (cached) { currentSpot = cached.payload; renderScore(cached.payload, true, cached.fetchedAt); } else { signalStatusEl.textContent = "Kein Live-Score und kein Cache für diesen Spot vorhanden."; } } finally { loadScoreEl.disabled = false; } } function parseCoordinateInput(value) { if (typeof value !== "string") { return Number.NaN; } const normalized = value.trim().replace(",", "."); if (!normalized) { return Number.NaN; } return Number(normalized); } function renderScore(data, fromCache, cacheTime = "") { scoreEl.textContent = String(data.score); ampelEl.classList.remove("green", "yellow", "red"); ampelEl.classList.add(data.ampel); ampelEl.textContent = data.ampel === "green" ? "Grün" : data.ampel === "yellow" ? "Gelb" : "Rot"; nightWindowEl.textContent = `Bezug: ${toLocal(data.night_window.start)} bis ${toLocal(data.night_window.end)}`; reasonsEl.innerHTML = ""; data.reasons.forEach((reason) => { const li = document.createElement("li"); li.textContent = reason; reasonsEl.appendChild(li); }); const quality = (data.meta && data.meta.quality) || null; qualityBadgeEl.classList.remove("high", "medium", "low"); if (quality && quality.level) { qualityBadgeEl.classList.add(quality.level); qualityBadgeEl.textContent = `${quality.label} (${quality.score})`; } else { qualityBadgeEl.textContent = "-"; } factorDetailsEl.innerHTML = ""; const details = (data.explanation && data.explanation.factors) || data.factors || []; if (!details.length) { const empty = document.createElement("li"); empty.textContent = "Keine Faktoren vorhanden."; factorDetailsEl.appendChild(empty); } else { details.slice(0, 8).forEach((item) => { const li = document.createElement("li"); li.textContent = `${item.label} (${Number(item.points).toFixed(1)}) | ${item.source}`; factorDetailsEl.appendChild(li); }); } const spotCtx = (data.explanation && data.explanation.spot_context) || null; if (spotCtx) { spotContextEl.textContent = `Spot-Kontext: area ${spotCtx.area_type}, road ${spotCtx.road_type}, ` + `Polizei ${formatMeters(spotCtx.distance_police_m)}, ` + `Feuerwehr ${formatMeters(spotCtx.distance_fire_m)}, ` + `Krankenhaus ${formatMeters(spotCtx.distance_hospital_m)}`; } else { spotContextEl.textContent = "Spot-Kontext: -"; } const health = (data.meta && data.meta.health) || {}; if (health.has_data) { const freshness = `freshest ${health.freshest_age_hours}h, stalest ${health.stalest_age_hours}h`; const stale = health.stale_sources && health.stale_sources.length ? `, stale: ${health.stale_sources.join(", ")}` : ""; const fallback = data.meta.used_fallback_pois ? ", Fallback-POI aktiv" : ""; const degraded = data.meta.degraded ? ", degradierter Modus" : ""; dataStatusEl.textContent = `Datenstand: ${freshness}${stale}${fallback}${degraded}`; } else { dataStatusEl.textContent = data.meta && data.meta.degraded ? "Datenstand: degradierter Modus (Fallback-Berechnung)" : "Datenstand: keine Quellenmetadaten"; } if (fromCache) { signalStatusEl.textContent = `Cache verwendet (Stand: ${toLocal(cacheTime)}).`; } else if (data.meta && data.meta.degraded) { signalStatusEl.textContent = "Live-Score im Fallback-Modus (eingeschränkte Datenbasis)."; } else { signalStatusEl.textContent = "Live-Score erfolgreich geladen."; } } function formatMeters(value) { const n = Number(value); if (!Number.isFinite(n)) { return "-"; } if (n >= 1000) { return `${(n / 1000).toFixed(1)} km`; } return `${Math.round(n)} m`; } function buildSignal(signalType) { if (!currentSpot || !currentSpot.spot_id) { return null; } return { spot_id: currentSpot.spot_id, signal_type: signalType, device_token: deviceToken, timestamp: new Date().toISOString(), }; } async function sendSignal(signalType) { if (!settings.signalsEnabled) { signalStatusEl.textContent = "Community Signals sind in den Settings deaktiviert."; return; } const signal = buildSignal(signalType); if (!signal) { signalStatusEl.textContent = "Bitte zuerst einen Spot-Score laden."; return; } try { await submitSignal(signal); signalStatusEl.textContent = `Signal '${signalType}' wurde gespeichert.`; } catch (error) { if (error && String(error.message || "").startsWith("cooldown:")) { const nextAt = String(error.message).replace("cooldown:", ""); signalStatusEl.textContent = `Signal gesperrt bis ${toLocal(nextAt)}.`; return; } signalQueue.push(signal); saveJSON(SIGNAL_QUEUE_KEY, signalQueue); signalStatusEl.textContent = `Offline/Fehler: Signal '${signalType}' zwischengespeichert.`; renderQueueStatus(); } } async function submitSignal(signal) { const response = await fetch(`${API_BASE}/spot/signal`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(signal), }); if (!response.ok) { const payload = await response.json().catch(() => ({})); if (payload && payload.error === "cooldown_active") { throw new Error(`cooldown:${payload.next_allowed_at || ""}`); } throw new Error("signal_failed"); } } async function flushSignalQueue() { if (!signalQueue.length) { return; } const pending = [...signalQueue]; const keep = []; for (const signal of pending) { try { await submitSignal(signal); } catch { keep.push(signal); } } signalQueue = keep; saveJSON(SIGNAL_QUEUE_KEY, signalQueue); renderQueueStatus(); if (!keep.length && pending.length) { signalStatusEl.textContent = "Alle zwischengespeicherten Signale wurden synchronisiert."; } } function toLocal(iso) { if (!iso) return "-"; return new Date(iso).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short", }); }