diff --git a/backend/server.py b/backend/server.py index 031220c..c6dba35 100644 --- a/backend/server.py +++ b/backend/server.py @@ -476,6 +476,98 @@ def source_health(sources: list[dict], now: dt.datetime) -> dict: } +def classify_quality( + health: dict, used_fallback_pois: bool, degraded: bool, factor_count: int, community_factor_count: int +) -> dict: + score = 100 + reasons = [] + + if degraded: + score -= 45 + reasons.append("Backend im degradierten Modus") + if used_fallback_pois: + score -= 20 + reasons.append("Fallback-POIs statt Live-POIs") + if not health.get("has_data"): + score -= 20 + reasons.append("Keine Quellenmetadaten") + else: + stalest_age = health.get("stalest_age_hours") + if isinstance(stalest_age, (int, float)): + if stalest_age > 48: + score -= 20 + reasons.append("Mindestens eine Quelle >48h alt") + elif stalest_age > 24: + score -= 10 + reasons.append("Mindestens eine Quelle >24h alt") + stale_count = len(health.get("stale_sources") or []) + if stale_count >= 2: + score -= 10 + reasons.append("Mehrere stale Quellen") + elif stale_count == 1: + score -= 5 + reasons.append("Eine stale Quelle") + + if factor_count <= 2: + score -= 8 + reasons.append("Wenig Score-Faktoren verfuegbar") + if community_factor_count == 0: + score -= 5 + reasons.append("Keine aktuellen Community-Signale") + elif community_factor_count >= 2: + score += 4 + reasons.append("Mehrere aktuelle Community-Signale") + + score = max(0, min(100, int(round(score)))) + if score >= 75: + level = "high" + label = "hoch" + elif score >= 45: + level = "medium" + label = "mittel" + else: + level = "low" + label = "niedrig" + + return { + "score": score, + "level": level, + "label": label, + "reasons": reasons[:4], + } + + +def build_explanation(factors: list[dict], spot: dict) -> dict: + details = [] + for item in factors: + points = float(item["points"]) + impact = "neutral" + if points > 0: + impact = "positive" + elif points < 0: + impact = "negative" + details.append( + { + "key": item["key"], + "label": item["label"], + "points": round(points, 2), + "source": item["source"], + "impact": impact, + } + ) + + return { + "factors": details, + "spot_context": { + "area_type": spot.get("osm_area_type", "unknown"), + "road_type": spot.get("road_type", "unknown"), + "distance_police_m": int(spot.get("distance_police_m", 5000)), + "distance_fire_m": int(spot.get("distance_fire_m", 5000)), + "distance_hospital_m": int(spot.get("distance_hospital_m", 5000)), + }, + } + + def compute_score_payload(lat: float, lon: float, at_time: dt.datetime) -> dict: now_iso = to_iso(utc_now()) spot = ensure_spot(lat, lon, now_iso) @@ -504,8 +596,16 @@ def compute_score_payload(lat: float, lon: float, at_time: dt.datetime) -> dict: raw_score = 100.0 + sum(item["points"] for item in factors) score = clamp_score(raw_score) - top_reasons = sorted(factors, key=lambda item: abs(item["points"]), reverse=True)[:4] + sorted_factors = sorted(factors, key=lambda item: abs(item["points"]), reverse=True) + top_reasons = sorted_factors[:4] reasons = [f"{item['label']} ({item['points']:+.0f})" for item in top_reasons] + quality = classify_quality( + health=health, + used_fallback_pois=bool(spot.get("used_fallback_pois", False)), + degraded=False, + factor_count=len(sorted_factors), + community_factor_count=sum(1 for item in factors if item.get("source") == "community"), + ) return { "spot_id": spot["id"], @@ -513,6 +613,7 @@ def compute_score_payload(lat: float, lon: float, at_time: dt.datetime) -> dict: "ampel": ampel(score), "reasons": reasons, "factors": top_reasons, + "explanation": build_explanation(sorted_factors, spot), "night_window": { "start": to_iso(night_start), "end": to_iso(night_end), @@ -524,6 +625,7 @@ def compute_score_payload(lat: float, lon: float, at_time: dt.datetime) -> dict: "sources": sources, "health": health, "used_fallback_pois": bool(spot.get("used_fallback_pois", False)), + "quality": quality, }, } @@ -550,8 +652,24 @@ def compute_score_payload_fallback(lat: float, lon: float, at_time: dt.datetime, raw_score = 100.0 + sum(item["points"] for item in factors) score = clamp_score(raw_score) - top_reasons = sorted(factors, key=lambda item: abs(item["points"]), reverse=True)[:4] + sorted_factors = sorted(factors, key=lambda item: abs(item["points"]), reverse=True) + top_reasons = sorted_factors[:4] reasons = [f"{item['label']} ({item['points']:+.0f})" for item in top_reasons] or ["Fallback-Berechnung aktiv (0)"] + fallback_spot = { + "osm_area_type": "residential", + "road_type": "unknown", + "distance_police_m": police_d, + "distance_fire_m": fire_d, + "distance_hospital_m": hosp_d, + } + health = {"freshest_age_hours": None, "stalest_age_hours": None, "stale_sources": [], "has_data": False} + quality = classify_quality( + health=health, + used_fallback_pois=True, + degraded=True, + factor_count=len(sorted_factors), + community_factor_count=0, + ) return { "spot_id": spot_id_for(lat, lon), @@ -559,6 +677,7 @@ def compute_score_payload_fallback(lat: float, lon: float, at_time: dt.datetime, "ampel": ampel(score), "reasons": reasons, "factors": top_reasons, + "explanation": build_explanation(sorted_factors, fallback_spot), "night_window": { "start": to_iso(night_start), "end": to_iso(night_end), @@ -568,10 +687,11 @@ def compute_score_payload_fallback(lat: float, lon: float, at_time: dt.datetime, "region": "DE-NW (Pilot: Kreis Mettmann)", "attribution": "Kartendaten: OpenStreetMap-Mitwirkende (ODbL)", "sources": [], - "health": {"freshest_age_hours": None, "stalest_age_hours": None, "stale_sources": [], "has_data": False}, + "health": health, "used_fallback_pois": True, "degraded": True, "degraded_reason": error_code, + "quality": quality, }, } diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index fd5bfcf..85376a9 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -74,6 +74,8 @@ Entscheidung in <10 Sekunden: Ist ein Spot heute Nacht (22-06) voraussichtlich r ## Naechste Schritte -1. Reale NRW/Kommunal-URLs in `open_data_sources.json` aktivieren -2. Health-Checks + Alerting fuer fehlgeschlagene Importjobs -3. Postgres + PostGIS Migration +1. Monitoring/Alerting fuer API und Importjobs produktiv aufsetzen +2. "Ruhigere Alternativen im Umkreis" als Quick-Action integrieren +3. Opendata-Quellen in NRW pro Kommune schrittweise erweitern + +Siehe auch: `docs/ROADMAP_30_60_90.md` diff --git a/docs/ROADMAP_30_60_90.md b/docs/ROADMAP_30_60_90.md new file mode 100644 index 0000000..9d275e5 --- /dev/null +++ b/docs/ROADMAP_30_60_90.md @@ -0,0 +1,47 @@ +# StaySense Roadmap 30/60/90 + +Stand: 2026-02-16 + +## Priorisierung + +Top-3 mit direktem Nutzwert fuer die WebApp: + +1. Transparente Score-Erklaerung (Faktoren + Spot-Kontext) +2. Datenqualitaetsindikator je Score +3. Karten-UX: Standort per verschiebbarem Pin auswaehlen + +## Umsetzung Top-3 (abgeschlossen) + +- [x] Transparente Score-Erklaerung + - API liefert jetzt `explanation.factors` und `explanation.spot_context` + - Frontend zeigt Faktorenliste und Distanz-Kontext (Polizei/Feuerwehr/Krankenhaus) +- [x] Datenqualitaetsindikator + - API liefert `meta.quality` mit `level`, `label`, `score`, `reasons` + - Frontend zeigt Badge `hoch/mittel/niedrig` +- [x] Karten-UX verbessert + - Marker als verschiebbarer Pin (drag & drop) + - Koordinatenfeld wird beim Verschieben sofort aktualisiert + +## 0-30 Tage + +- Monitoring fuer API/Import (Uptime, Error-Rate, Alarmierung) +- Score-Engine Observability: + - Logging fuer degradierte Scores und Fallback-Nutzung +- Admin UX: + - Filter fuer Events/Signals nach Zeitraum + - Bessere Fehlermeldungen je API-Error + +## 31-60 Tage + +- "Alternativen in der Naehe" (ruhigere Spots im Radius) +- Community-Signale erweitern: + - optionale Strukturfelder (z. B. Intensitaet, Dauer) +- Opendata-Quellen NRW ausbauen: + - weitere Kommunen fuer Events/Baustellen + +## 61-90 Tage + +- Persistenter Betrieb auf PostgreSQL (optional PostGIS) +- Anomalie-Erkennung gegen Signal-Missbrauch +- Exportierbare API-Doku (OpenAPI) und versionierte API (`/v1`) + diff --git a/src/app.js b/src/app.js index f456a2b..d4aea5d 100644 --- a/src/app.js +++ b/src/app.js @@ -24,6 +24,9 @@ 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"); @@ -240,6 +243,7 @@ function initializeMap() { 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); @@ -248,16 +252,20 @@ function setCoordinates(lat, lon, options = {}) { } const latLng = [Number(lat), Number(lon)]; - if (!mapMarker) { - mapMarker = L.circleMarker(latLng, { - radius: 8, - color: "#006680", - fillColor: "#1ca4c7", - fillOpacity: 0.8, - weight: 2, - }).addTo(map); - } else { - mapMarker.setLatLng(latLng); + 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)) { @@ -684,6 +692,40 @@ function renderScore(data, fromCache, cacheTime = "") { 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`; @@ -706,6 +748,17 @@ function renderScore(data, fromCache, cacheTime = "") { } } +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; diff --git a/src/index.html b/src/index.html index 27f3efb..32c996e 100644 --- a/src/index.html +++ b/src/index.html @@ -67,10 +67,19 @@
--
-
+
+ Datenqualitaet: + - +

Bezug: Heute Nacht 22:00-06:00

+

Score-Faktoren

+ +
Spot-Kontext: -

Community Signal

diff --git a/src/styles.css b/src/styles.css index 21613a6..197f406 100644 --- a/src/styles.css +++ b/src/styles.css @@ -197,6 +197,21 @@ select { border: 1px solid var(--line); } +.spot-pin { + width: 16px; + height: 16px; +} + +.spot-pin span { + display: block; + width: 16px; + height: 16px; + border-radius: 999px; + border: 2px solid #fff; + background: #1ca4c7; + box-shadow: 0 2px 8px rgba(6, 31, 43, 0.35); +} + .btn { border: none; border-radius: 10px; @@ -263,6 +278,68 @@ select { padding-left: 18px; } +.subheading { + margin-top: 12px; + margin-bottom: 6px; + font-size: 0.95rem; +} + +.quality-wrap { + margin-top: 8px; + display: flex; + gap: 8px; + align-items: center; +} + +.quality-label { + color: var(--muted); + font-size: 0.85rem; +} + +.quality-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 72px; + border-radius: 999px; + padding: 3px 10px; + color: #fff; + background: #5f7280; + font-size: 0.82rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.quality-badge.high { + background: var(--green); +} + +.quality-badge.medium { + background: var(--yellow); +} + +.quality-badge.low { + background: var(--red); +} + +.factor-details { + margin: 6px 0 0; + padding-left: 18px; + color: var(--muted); +} + +.factor-details li { + margin-bottom: 4px; +} + +.spot-context { + margin-top: 8px; + color: var(--muted); + font-size: 0.84rem; + border-top: 1px dashed var(--line); + padding-top: 8px; +} + .signal-grid { display: grid; gap: 8px;