const API_BASE = window.STAYSENSE_API_BASE || "http://127.0.0.1:8787"; 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 MAX_CACHE_ITEMS = 50; const latEl = document.getElementById("lat"); const lonEl = document.getElementById("lon"); 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 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"); let currentSpot = null; let scoreCache = loadJSON(SCORE_CACHE_KEY, []); let signalQueue = loadJSON(SIGNAL_QUEUE_KEY, []); let settings = loadJSON(SETTINGS_KEY, { signalsEnabled: true }); const deviceToken = ensureDeviceToken(); initialize(); function initialize() { signalsEnabledEl.checked = Boolean(settings.signalsEnabled); updateNetworkStatus(); window.addEventListener("online", onOnline); window.addEventListener("offline", updateNetworkStatus); signalsEnabledEl.addEventListener("change", () => { settings.signalsEnabled = signalsEnabledEl.checked; saveJSON(SETTINGS_KEY, settings); }); useLocationEl.addEventListener("click", fillLocationFromDevice); loadScoreEl.addEventListener("click", loadScore); document.querySelectorAll(".signal").forEach((btn) => { btn.addEventListener("click", () => sendSignal(btn.dataset.signal)); }); document.getElementById("show-attribution").addEventListener("click", (e) => { e.preventDefault(); legalOutputEl.textContent = "Kartendaten: OpenStreetMap-Mitwirkende (ODbL). Open Data NRW: jeweilige Quellen mit Namensnennung."; }); document.getElementById("show-privacy").addEventListener("click", (e) => { e.preventDefault(); legalOutputEl.textContent = "Kein Login, keine IP-Speicherung, kein Fingerprinting. Missbrauchsschutz via gehashtem lokalem Zufallstoken (HMAC-SHA256)."; }); document.getElementById("show-imprint").addEventListener("click", (e) => { e.preventDefault(); legalOutputEl.textContent = "MVP-Hinweis: Impressum im Produktionsbetrieb verpflichtend mit Anbieterkennzeichnung."; }); // Pilotwert fuer Mettmann, falls noch keine Eingabe. if (!latEl.value && !lonEl.value) { latEl.value = "51.2500"; lonEl.value = "6.9730"; } flushSignalQueue(); renderQueueStatus(); } 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 updateNetworkStatus() { networkStatusEl.textContent = navigator.onLine ? "Online" : "Offline"; } function onOnline() { updateNetworkStatus(); flushSignalQueue(); } function renderQueueStatus() { queueStatusEl.textContent = `Queue: ${signalQueue.length} ausstehend`; } async function fillLocationFromDevice() { if (!navigator.geolocation) { alert("Geolocation wird auf diesem Geraet nicht unterstuetzt."); return; } useLocationEl.disabled = true; navigator.geolocation.getCurrentPosition( (position) => { latEl.value = position.coords.latitude.toFixed(6); lonEl.value = position.coords.longitude.toFixed(6); useLocationEl.disabled = false; }, () => { alert("Standort konnte nicht gelesen werden."); useLocationEl.disabled = false; }, { enableHighAccuracy: true, maximumAge: 60000, timeout: 7000 } ); } 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 = Number(latEl.value); const lon = Number(lonEl.value); if (!Number.isFinite(lat) || !Number.isFinite(lon)) { alert("Bitte gueltige Koordinaten eingeben."); return; } 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) { 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 fuer diesen Spot vorhanden."; } } finally { loadScoreEl.disabled = false; } } 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" ? "Gruen" : 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 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" : ""; dataStatusEl.textContent = `Datenstand: ${freshness}${stale}${fallback}`; } else { dataStatusEl.textContent = "Datenstand: keine Quellenmetadaten"; } if (fromCache) { signalStatusEl.textContent = `Cache verwendet (Stand: ${toLocal(cacheTime)}).`; } else { signalStatusEl.textContent = "Live-Score erfolgreich geladen."; } } 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}' gequeued.`; 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 (!navigator.onLine || !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 gequeueten Signale wurden synchronisiert."; } } function toLocal(iso) { if (!iso) return "-"; return new Date(iso).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short", }); }