commit 902988276c9a19a8529b63774fa26aa6a15b1889 Author: Oliver G Date: Sun Feb 15 13:08:56 2026 +0100 feat: implement StaySense MVP backend, frontend, imports, and deployment docs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9744601 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Python +__pycache__/ +*.py[cod] + +# macOS +.DS_Store + +# Local runtime data +data/*.db +data/*.sqlite + +# Local logs / temp +*.log +*.tmp diff --git a/README.md b/README.md new file mode 100644 index 0000000..43d4a7f --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# StaySense (MVP NRW) + +StaySense bewertet fuer die Nacht (22:00-06:00) die voraussichtliche Ruhe eines Spots in NRW (Pilot: Kreis Mettmann). + +## MVP Status + +Implementiert: +- Night Safety Score (0-100) mit Ampel (`green`, `yellow`, `red`) +- Begruendung mit Top-Faktoren (OSM-Typ, Distanz-POIs, Zeitlogik, Events, Community) +- API: `GET /spot/score`, `POST /spot/signal` +- Anti-Spam ohne Account: lokaler Token + serverseitiger HMAC-Hash +- Quick Decision UI mit Signal-Buttons +- Offline-First im Frontend: Score-Cache + Signal-Queue +- Echter OSM-Import via Overpass (POIs, Landuse-Zonen, Highway-Typen) +- OpenData-Connectoren (CSV/JSON aus URL oder Datei) +- Automatischer Import-Job-Runner (`once` oder `daemon`) + +Nicht im MVP: +- Accounts/Login +- Chat/Kommentare +- Stellplatz-Portal +- Routenplanung + +## Start + +1. Datenbank initialisieren: + - `cd StaySense/backend` + - `python3 -c "from db import init_db; init_db()"` +2. OSM importieren (Pilot-BBox Mettmann): + - `python3 import_osm_overpass.py` +3. OpenData-Connectoren aus Konfig ausfuehren: + - `python3 run_import_jobs.py --config ../docs/open_data_sources.json --prune-legacy` +4. Optional: periodische Imports (alle 6h) inkl. OSM: + - `python3 run_import_jobs.py --config ../docs/open_data_sources.json --with-osm --prune-legacy --daemon --interval-seconds 21600` +5. API starten: + - `python3 server.py` +6. Frontend starten (zweites Terminal): + - `cd ../src` + - `python3 -m http.server 8080` +7. App oeffnen: + - `http://localhost:8080` + +## OpenData Connector Config + +Datei: `docs/open_data_sources.json` + +- `enabled`: Quelle aktivieren/deaktivieren +- `format`: `csv` oder `json` +- `url` oder `file`: Quelle +- `field_map`: Mapping auf StaySense-Felder +- `event_type_map`: optionale Typ-Normalisierung +- `json_path`: Pfad auf Array bei JSON-Feeds +- Validierung aktiv: + - Koordinaten muessen in DE-Bounds liegen + - `start_datetime < end_datetime` + - Event-Typ muss auf `market|waste|event|construction` normalisiert werden + +## Tests + +- Backend Unit-Tests: + - `cd StaySense/backend` + - `python3 -m unittest discover -s tests -p \"test_*.py\"` + +## API Beispiele + +Score holen: +- `curl "http://127.0.0.1:8787/spot/score?lat=51.25&lon=6.97&at=2026-02-15T20:00:00Z"` + +Signal senden: +- `curl -X POST "http://127.0.0.1:8787/spot/signal" -H "Content-Type: application/json" -d '{"spot_id":"","signal_type":"noise","device_token":"","timestamp":"2026-02-15T20:15:00Z"}'` + +## DSGVO-Hinweise im MVP + +- Kein Login +- Keine IP-Speicherung in der Anwendung +- Kein Device Fingerprinting +- Missbrauchsschutz nur ueber `hashed_device = HMAC_SHA256(device_token, server_salt)` + +## Produktion + +- `STAYSENSE_SERVER_SALT` zwingend als geheimes Env setzen +- HTTPS-only bereitstellen +- OpenStreetMap-/Open-Data-Attribution in App verpflichtend sichtbar halten + +## Doku Uebersicht + +- Architektur / Umsetzung: `docs/IMPLEMENTATION_PLAN.md` +- Server-Deployment (Linux + Nginx + systemd): `docs/DEPLOYMENT.md` +- Betrieb / Runbook: `docs/OPERATIONS.md` +- GitHub Publish Ablauf: `docs/GITHUB_PUBLISH.md` +- Connector-Konfiguration: `docs/open_data_sources.json` +- Event-Template: `docs/open_data_events_template.csv` diff --git a/backend/db.py b/backend/db.py new file mode 100644 index 0000000..ac2f19d --- /dev/null +++ b/backend/db.py @@ -0,0 +1,122 @@ +import sqlite3 +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent +DB_PATH = BASE_DIR / "data" / "staysense.db" + + +def get_conn() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + return conn + + +def init_db() -> None: + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + with get_conn() as conn: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS spot ( + id TEXT PRIMARY KEY, + lat REAL NOT NULL, + lon REAL NOT NULL, + osm_area_type TEXT NOT NULL CHECK (osm_area_type IN ('residential', 'industrial', 'commercial', 'parking', 'nature')), + road_type TEXT NOT NULL CHECK (road_type IN ('residential', 'primary', 'secondary', 'service', 'unknown')), + distance_police_m INTEGER NOT NULL, + distance_fire_m INTEGER NOT NULL, + distance_hospital_m INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS local_event ( + id TEXT PRIMARY KEY, + spot_id TEXT NOT NULL, + event_type TEXT NOT NULL CHECK (event_type IN ('market', 'waste', 'event', 'construction')), + start_datetime TEXT NOT NULL, + end_datetime TEXT NOT NULL, + risk_modifier INTEGER NOT NULL, + source TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (spot_id) REFERENCES spot(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS community_signal ( + id TEXT PRIMARY KEY, + spot_id TEXT NOT NULL, + signal_type TEXT NOT NULL CHECK (signal_type IN ('calm', 'noise', 'knock', 'police')), + hashed_device TEXT NOT NULL, + timestamp TEXT NOT NULL, + day_bucket TEXT NOT NULL, + FOREIGN KEY (spot_id) REFERENCES spot(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS osm_poi ( + id TEXT PRIMARY KEY, + poi_type TEXT NOT NULL CHECK (poi_type IN ('police', 'fire', 'hospital')), + lat REAL NOT NULL, + lon REAL NOT NULL, + source TEXT NOT NULL, + imported_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS osm_zone ( + id TEXT PRIMARY KEY, + zone_type TEXT NOT NULL CHECK (zone_type IN ('residential', 'industrial', 'commercial', 'parking', 'nature')), + lat REAL NOT NULL, + lon REAL NOT NULL, + source TEXT NOT NULL, + imported_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS osm_road ( + id TEXT PRIMARY KEY, + road_type TEXT NOT NULL CHECK (road_type IN ('residential', 'primary', 'secondary', 'service')), + lat REAL NOT NULL, + lon REAL NOT NULL, + source TEXT NOT NULL, + imported_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS open_data_event ( + id TEXT PRIMARY KEY, + event_type TEXT NOT NULL CHECK (event_type IN ('market', 'waste', 'event', 'construction')), + lat REAL NOT NULL, + lon REAL NOT NULL, + start_datetime TEXT NOT NULL, + end_datetime TEXT NOT NULL, + risk_modifier INTEGER NOT NULL, + source TEXT NOT NULL, + imported_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS data_source_state ( + source_name TEXT PRIMARY KEY, + imported_at TEXT NOT NULL, + record_count INTEGER NOT NULL, + notes TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_signal_spot_timestamp + ON community_signal (spot_id, timestamp); + + CREATE UNIQUE INDEX IF NOT EXISTS ux_signal_spot_device_day + ON community_signal (spot_id, hashed_device, day_bucket); + + CREATE INDEX IF NOT EXISTS idx_local_event_spot_window + ON local_event (spot_id, start_datetime, end_datetime); + + CREATE INDEX IF NOT EXISTS idx_osm_poi_type + ON osm_poi (poi_type); + + CREATE INDEX IF NOT EXISTS idx_osm_zone_type + ON osm_zone (zone_type); + + CREATE INDEX IF NOT EXISTS idx_open_data_event_window + ON open_data_event (start_datetime, end_datetime); + + CREATE INDEX IF NOT EXISTS idx_open_data_event_source + ON open_data_event (source); + """ + ) diff --git a/backend/import_open_data_events.py b/backend/import_open_data_events.py new file mode 100644 index 0000000..9413bab --- /dev/null +++ b/backend/import_open_data_events.py @@ -0,0 +1,52 @@ +import argparse +import csv +import json +from pathlib import Path + +from open_data_connector import import_event_rows + + +def import_events(csv_path: Path) -> dict: + rows = [] + with csv_path.open("r", encoding="utf-8") as handle: + reader = csv.DictReader(handle) + required = {"lat", "lon", "event_type", "start_datetime", "end_datetime", "risk_modifier"} + if not required.issubset(set(reader.fieldnames or [])): + raise ValueError(f"CSV missing required columns: {sorted(required)}") + + for line in reader: + rows.append( + { + "id": None, + "event_type": (line.get("event_type") or "").strip(), + "lat": (line.get("lat") or "").strip(), + "lon": (line.get("lon") or "").strip(), + "start_datetime": (line.get("start_datetime") or "").strip(), + "end_datetime": (line.get("end_datetime") or "").strip(), + "risk_modifier": (line.get("risk_modifier") or "0").strip(), + } + ) + + # Legacy CSV import keeps source-name bound to filename. + source_name = f"open_data_events:{csv_path.name}" + normalized = [] + for idx, row in enumerate(rows): + row["id"] = f"{source_name}:{idx}" + normalized.append(row) + + result = import_event_rows(normalized, source_name=source_name, notes=str(csv_path)) + result["source"] = str(csv_path) + return result + + +def main() -> None: + parser = argparse.ArgumentParser(description="Import Open Data events CSV for StaySense") + parser.add_argument("--file", required=True, help="CSV with lat,lon,event_type,start_datetime,end_datetime,risk_modifier,source") + args = parser.parse_args() + + result = import_events(Path(args.file)) + print(json.dumps(result, ensure_ascii=True)) + + +if __name__ == "__main__": + main() diff --git a/backend/import_osm_overpass.py b/backend/import_osm_overpass.py new file mode 100644 index 0000000..10fb6d3 --- /dev/null +++ b/backend/import_osm_overpass.py @@ -0,0 +1,209 @@ +import argparse +import datetime as dt +import json +import uuid +from urllib import parse, request + +from db import get_conn, init_db + +OVERPASS_URL = "https://overpass-api.de/api/interpreter" +DEFAULT_BBOX = (51.16, 6.79, 51.38, 7.15) # Kreis Mettmann approx: s,w,n,e + + +def now_iso() -> str: + return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def build_query(south: float, west: float, north: float, east: float) -> str: + bbox = f"({south},{west},{north},{east})" + return f""" +[out:json][timeout:120]; +( + node[amenity=police]{bbox}; + way[amenity=police]{bbox}; + relation[amenity=police]{bbox}; + + node[amenity=fire_station]{bbox}; + way[amenity=fire_station]{bbox}; + relation[amenity=fire_station]{bbox}; + + node[amenity=hospital]{bbox}; + way[amenity=hospital]{bbox}; + relation[amenity=hospital]{bbox}; + + way[landuse=residential]{bbox}; + relation[landuse=residential]{bbox}; + + way[landuse=industrial]{bbox}; + relation[landuse=industrial]{bbox}; + + way[landuse=commercial]{bbox}; + relation[landuse=commercial]{bbox}; + + node[amenity=parking]{bbox}; + way[amenity=parking]{bbox}; + relation[amenity=parking]{bbox}; + + way[natural=wood]{bbox}; + relation[natural=wood]{bbox}; + way[leisure=nature_reserve]{bbox}; + relation[leisure=nature_reserve]{bbox}; + + way[highway=primary]{bbox}; + way[highway=secondary]{bbox}; + way[highway=residential]{bbox}; + way[highway=service]{bbox}; +); +out center; +""" + + +def element_coords(element: dict) -> tuple[float, float] | None: + if "lat" in element and "lon" in element: + return float(element["lat"]), float(element["lon"]) + center = element.get("center") + if center and "lat" in center and "lon" in center: + return float(center["lat"]), float(center["lon"]) + return None + + +def map_poi(tags: dict) -> str | None: + amenity = tags.get("amenity") + if amenity == "police": + return "police" + if amenity == "fire_station": + return "fire" + if amenity == "hospital": + return "hospital" + return None + + +def map_zone(tags: dict) -> str | None: + landuse = tags.get("landuse") + amenity = tags.get("amenity") + natural = tags.get("natural") + leisure = tags.get("leisure") + + if landuse == "residential": + return "residential" + if landuse == "industrial": + return "industrial" + if landuse == "commercial": + return "commercial" + if amenity == "parking": + return "parking" + if natural in {"wood", "scrub", "heath"} or leisure == "nature_reserve": + return "nature" + return None + + +def map_road(tags: dict) -> str | None: + highway = tags.get("highway") + if highway in {"primary", "secondary", "residential", "service"}: + return highway + return None + + +def fetch_overpass(query: str) -> dict: + payload = parse.urlencode({"data": query}).encode("utf-8") + req = request.Request(OVERPASS_URL, data=payload, method="POST") + with request.urlopen(req, timeout=180) as resp: + raw = resp.read() + return json.loads(raw.decode("utf-8")) + + +def import_osm(south: float, west: float, north: float, east: float) -> dict: + init_db() + data = fetch_overpass(build_query(south, west, north, east)) + elements = data.get("elements", []) + + imported_at = now_iso() + source = "osm_overpass" + + poi_rows = [] + zone_rows = [] + road_rows = [] + + for element in elements: + tags = element.get("tags", {}) + coords = element_coords(element) + if not coords: + continue + + lat, lon = coords + base_id = f"{element.get('type', 'x')}:{element.get('id', uuid.uuid4())}" + + poi_type = map_poi(tags) + if poi_type: + poi_rows.append((f"poi:{base_id}", poi_type, lat, lon, source, imported_at)) + + zone_type = map_zone(tags) + if zone_type: + zone_rows.append((f"zone:{base_id}", zone_type, lat, lon, source, imported_at)) + + road_type = map_road(tags) + if road_type: + road_rows.append((f"road:{base_id}", road_type, lat, lon, source, imported_at)) + + with get_conn() as conn: + conn.execute("DELETE FROM osm_poi WHERE source = ?", (source,)) + conn.execute("DELETE FROM osm_zone WHERE source = ?", (source,)) + conn.execute("DELETE FROM osm_road WHERE source = ?", (source,)) + + conn.executemany( + """ + INSERT INTO osm_poi (id, poi_type, lat, lon, source, imported_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + poi_rows, + ) + conn.executemany( + """ + INSERT INTO osm_zone (id, zone_type, lat, lon, source, imported_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + zone_rows, + ) + conn.executemany( + """ + INSERT INTO osm_road (id, road_type, lat, lon, source, imported_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + road_rows, + ) + + total = len(poi_rows) + len(zone_rows) + len(road_rows) + conn.execute( + """ + INSERT INTO data_source_state (source_name, imported_at, record_count, notes) + VALUES (?, ?, ?, ?) + ON CONFLICT(source_name) DO UPDATE SET + imported_at = excluded.imported_at, + record_count = excluded.record_count, + notes = excluded.notes + """, + ("osm_overpass", imported_at, total, f"bbox={south},{west},{north},{east}"), + ) + + return { + "elements": len(elements), + "pois": len(poi_rows), + "zones": len(zone_rows), + "roads": len(road_rows), + } + + +def main() -> None: + parser = argparse.ArgumentParser(description="Import OSM data for StaySense from Overpass") + parser.add_argument("--south", type=float, default=DEFAULT_BBOX[0]) + parser.add_argument("--west", type=float, default=DEFAULT_BBOX[1]) + parser.add_argument("--north", type=float, default=DEFAULT_BBOX[2]) + parser.add_argument("--east", type=float, default=DEFAULT_BBOX[3]) + args = parser.parse_args() + + result = import_osm(args.south, args.west, args.north, args.east) + print(json.dumps(result, ensure_ascii=True)) + + +if __name__ == "__main__": + main() diff --git a/backend/open_data_connector.py b/backend/open_data_connector.py new file mode 100644 index 0000000..3061275 --- /dev/null +++ b/backend/open_data_connector.py @@ -0,0 +1,317 @@ +import csv +import datetime as dt +import hashlib +import json +from pathlib import Path +from urllib import request + +from db import get_conn, init_db + +VALID_TYPES = {"market", "waste", "event", "construction"} +DE_BOUNDS = {"lat_min": 47.0, "lat_max": 55.5, "lon_min": 5.0, "lon_max": 16.0} + + +def now_iso() -> str: + return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def normalize_iso(value: str) -> str: + parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=dt.timezone.utc) + return parsed.astimezone(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _stable_id(source_name: str, external_id: str | None, payload: dict) -> str: + if external_id: + raw = f"{source_name}:{external_id}" + else: + raw = ( + f"{source_name}:{payload['event_type']}:{payload['lat']:.6f}:{payload['lon']:.6f}:" + f"{payload['start_datetime']}:{payload['end_datetime']}:{payload['risk_modifier']}" + ) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def _map_event_type(raw_value: str, event_type_map: dict[str, str], default_value: str | None) -> str | None: + raw = (raw_value or "").strip().lower() + if raw and raw in event_type_map: + raw = event_type_map[raw] + if not raw and default_value: + raw = default_value + if raw in VALID_TYPES: + return raw + return None + + +def _extract_json_records(payload: dict | list, json_path: str | None) -> list[dict]: + current = payload + if json_path: + for part in json_path.split("."): + if isinstance(current, dict): + current = current.get(part) + else: + current = None + if current is None: + return [] + if isinstance(current, list): + return [item for item in current if isinstance(item, dict)] + return [] + + +def _read_text(location: str, config_dir: Path) -> str: + if location.startswith("http://") or location.startswith("https://"): + with request.urlopen(location, timeout=60) as resp: + return resp.read().decode("utf-8", errors="replace") + + file_path = Path(location) + if not file_path.is_absolute(): + file_path = (config_dir / location).resolve() + return file_path.read_text(encoding="utf-8") + + +def import_event_rows(rows: list[dict], source_name: str, notes: str) -> dict: + init_db() + imported_at = now_iso() + + db_rows = [] + for item in rows: + event_type = item.get("event_type") + if event_type not in VALID_TYPES: + continue + + db_rows.append( + ( + item["id"], + event_type, + float(item["lat"]), + float(item["lon"]), + normalize_iso(item["start_datetime"]), + normalize_iso(item["end_datetime"]), + int(item["risk_modifier"]), + source_name, + imported_at, + ) + ) + + with get_conn() as conn: + conn.execute("DELETE FROM open_data_event WHERE source = ?", (source_name,)) + conn.executemany( + """ + INSERT INTO open_data_event ( + id, event_type, lat, lon, start_datetime, end_datetime, + risk_modifier, source, imported_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + db_rows, + ) + conn.execute( + """ + INSERT INTO data_source_state (source_name, imported_at, record_count, notes) + VALUES (?, ?, ?, ?) + ON CONFLICT(source_name) DO UPDATE SET + imported_at = excluded.imported_at, + record_count = excluded.record_count, + notes = excluded.notes + """, + (source_name, imported_at, len(db_rows), notes), + ) + + return {"rows": len(db_rows), "source_name": source_name} + + +def import_from_source(source_cfg: dict, config_dir: Path) -> dict: + source_name = source_cfg.get("source_name") or source_cfg.get("id") or "open_data_source" + location = source_cfg.get("url") or source_cfg.get("file") + if not location: + raise ValueError(f"source '{source_name}' missing url/file") + + source_format = (source_cfg.get("format") or "csv").lower() + field_map = source_cfg.get("field_map") or {} + defaults = source_cfg.get("default_values") or {} + event_type_map = {str(k).lower(): str(v).lower() for k, v in (source_cfg.get("event_type_map") or {}).items()} + json_path = source_cfg.get("json_path") + + text = _read_text(location, config_dir) + + records: list[dict] + if source_format == "csv": + reader = csv.DictReader(text.splitlines()) + records = [dict(row) for row in reader] + elif source_format == "json": + payload = json.loads(text) + records = _extract_json_records(payload, json_path) + else: + raise ValueError(f"unsupported format: {source_format}") + + rows = [] + stats = { + "input_records": len(records), + "accepted": 0, + "rejected_invalid_event_type": 0, + "rejected_parse_error": 0, + "rejected_missing_datetime": 0, + "rejected_out_of_bounds": 0, + "rejected_invalid_window": 0, + } + for record in records: + lat_key = field_map.get("lat", "lat") + lon_key = field_map.get("lon", "lon") + start_key = field_map.get("start_datetime", "start_datetime") + end_key = field_map.get("end_datetime", "end_datetime") + risk_key = field_map.get("risk_modifier", "risk_modifier") + event_type_key = field_map.get("event_type", "event_type") + external_id_key = field_map.get("external_id") + + raw_event_type = str(record.get(event_type_key, defaults.get("event_type", ""))) + event_type = _map_event_type(raw_event_type, event_type_map, defaults.get("event_type")) + if not event_type: + stats["rejected_invalid_event_type"] += 1 + continue + + try: + lat = float(str(record.get(lat_key, defaults.get("lat", ""))).strip()) + lon = float(str(record.get(lon_key, defaults.get("lon", ""))).strip()) + start_dt = str(record.get(start_key, defaults.get("start_datetime", "")).strip()) + end_dt = str(record.get(end_key, defaults.get("end_datetime", "")).strip()) + risk_modifier = int(str(record.get(risk_key, defaults.get("risk_modifier", "0"))).strip()) + except Exception: + stats["rejected_parse_error"] += 1 + continue + + if not start_dt or not end_dt: + stats["rejected_missing_datetime"] += 1 + continue + + if not ( + DE_BOUNDS["lat_min"] <= lat <= DE_BOUNDS["lat_max"] + and DE_BOUNDS["lon_min"] <= lon <= DE_BOUNDS["lon_max"] + ): + stats["rejected_out_of_bounds"] += 1 + continue + + try: + start_iso = normalize_iso(start_dt) + end_iso = normalize_iso(end_dt) + except Exception: + stats["rejected_parse_error"] += 1 + continue + + if end_iso <= start_iso: + stats["rejected_invalid_window"] += 1 + continue + + external_id = None + if external_id_key: + value = record.get(external_id_key) + external_id = str(value).strip() if value is not None else None + + payload = { + "event_type": event_type, + "lat": lat, + "lon": lon, + "start_datetime": start_iso, + "end_datetime": end_iso, + "risk_modifier": risk_modifier, + } + payload["id"] = _stable_id(source_name, external_id, payload) + rows.append(payload) + stats["accepted"] += 1 + + note = f"connector={source_cfg.get('id', source_name)} location={location} format={source_format}" + result = import_event_rows(rows, source_name=source_name, notes=note) + result["connector_id"] = source_cfg.get("id", source_name) + result["stats"] = stats + return result + + +def prune_sources_not_in_config(config_source_names: set[str], protected_sources: set[str] | None = None) -> dict: + protected = protected_sources or {"osm_overpass"} + with get_conn() as conn: + rows = conn.execute("SELECT DISTINCT source FROM open_data_event").fetchall() + existing_sources = {row["source"] for row in rows} + state_rows = conn.execute("SELECT source_name FROM data_source_state").fetchall() + existing_state_sources = {row["source_name"] for row in state_rows} + + to_prune = sorted((existing_sources - config_source_names) - protected) + state_to_mark = sorted((existing_state_sources - config_source_names) - protected) + pruned_rows = 0 + ts = now_iso() + for source_name in to_prune: + count = conn.execute("SELECT COUNT(*) AS c FROM open_data_event WHERE source = ?", (source_name,)).fetchone()["c"] + pruned_rows += int(count) + conn.execute("DELETE FROM open_data_event WHERE source = ?", (source_name,)) + for source_name in state_to_mark: + conn.execute( + """ + INSERT INTO data_source_state (source_name, imported_at, record_count, notes) + VALUES (?, ?, ?, ?) + ON CONFLICT(source_name) DO UPDATE SET + imported_at = excluded.imported_at, + record_count = excluded.record_count, + notes = excluded.notes + """, + (source_name, ts, 0, "pruned_not_in_config"), + ) + + return {"sources": sorted(set(to_prune + state_to_mark)), "rows": pruned_rows} + + +def import_from_config(config_path: Path, prune_legacy: bool = False) -> dict: + cfg = json.loads(config_path.read_text(encoding="utf-8")) + sources = cfg.get("sources") + if not isinstance(sources, list): + raise ValueError("config requires 'sources' list") + + imported = [] + skipped = [] + disabled_source_names = [] + all_source_names = set() + for source in sources: + if not isinstance(source, dict): + continue + source_name = source.get("source_name") or source.get("id") or "open_data_source" + all_source_names.add(source_name) + if not source.get("enabled", False): + skipped.append(source.get("id", "unknown")) + disabled_source_names.append(source_name) + continue + try: + imported.append(import_from_source(source, config_path.parent)) + except Exception as exc: + imported.append( + { + "connector_id": source.get("id", source_name), + "source_name": source_name, + "rows": 0, + "error": str(exc), + } + ) + + if disabled_source_names: + ts = now_iso() + with get_conn() as conn: + for source_name in disabled_source_names: + conn.execute("DELETE FROM open_data_event WHERE source = ?", (source_name,)) + conn.execute( + """ + INSERT INTO data_source_state (source_name, imported_at, record_count, notes) + VALUES (?, ?, ?, ?) + ON CONFLICT(source_name) DO UPDATE SET + imported_at = excluded.imported_at, + record_count = excluded.record_count, + notes = excluded.notes + """, + (source_name, ts, 0, "disabled in config"), + ) + + pruned = {"sources": [], "rows": 0} + if prune_legacy: + pruned = prune_sources_not_in_config(all_source_names) + + return { + "imported": imported, + "skipped": skipped, + "pruned": pruned, + "count": len(imported), + } diff --git a/backend/run_import_jobs.py b/backend/run_import_jobs.py new file mode 100644 index 0000000..30d20e0 --- /dev/null +++ b/backend/run_import_jobs.py @@ -0,0 +1,43 @@ +import argparse +import json +import time +from pathlib import Path + +from import_osm_overpass import DEFAULT_BBOX, import_osm +from open_data_connector import import_from_config + + +def run_once(config_path: Path, with_osm: bool, prune_legacy: bool, south: float, west: float, north: float, east: float) -> dict: + result = {"open_data": import_from_config(config_path, prune_legacy=prune_legacy)} + if with_osm: + result["osm"] = import_osm(south, west, north, east) + return result + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run StaySense data import jobs") + parser.add_argument("--config", default="../docs/open_data_sources.json", help="Path to open data source config JSON") + parser.add_argument("--with-osm", action="store_true", help="Also run OSM overpass import") + parser.add_argument("--prune-legacy", action="store_true", help="Delete open_data_event sources not present in config") + parser.add_argument("--daemon", action="store_true", help="Run continuously") + parser.add_argument("--interval-seconds", type=int, default=21600, help="Loop interval for daemon mode") + parser.add_argument("--south", type=float, default=DEFAULT_BBOX[0]) + parser.add_argument("--west", type=float, default=DEFAULT_BBOX[1]) + parser.add_argument("--north", type=float, default=DEFAULT_BBOX[2]) + parser.add_argument("--east", type=float, default=DEFAULT_BBOX[3]) + args = parser.parse_args() + + config_path = Path(args.config).resolve() + + if not args.daemon: + print(json.dumps(run_once(config_path, args.with_osm, args.prune_legacy, args.south, args.west, args.north, args.east), ensure_ascii=True)) + return + + while True: + result = run_once(config_path, args.with_osm, args.prune_legacy, args.south, args.west, args.north, args.east) + print(json.dumps(result, ensure_ascii=True)) + time.sleep(max(60, args.interval_seconds)) + + +if __name__ == "__main__": + main() diff --git a/backend/score_engine.py b/backend/score_engine.py new file mode 100644 index 0000000..3ae841e --- /dev/null +++ b/backend/score_engine.py @@ -0,0 +1,153 @@ +import datetime as dt +import math +import uuid +from dataclasses import dataclass + +NRW_HOLIDAYS_2026 = { + dt.date(2026, 1, 1), + dt.date(2026, 4, 3), + dt.date(2026, 4, 6), + dt.date(2026, 5, 1), + dt.date(2026, 5, 14), + dt.date(2026, 5, 25), + dt.date(2026, 6, 4), + dt.date(2026, 10, 3), + dt.date(2026, 11, 1), + dt.date(2026, 12, 25), + dt.date(2026, 12, 26), +} + +POLICE_POINTS = [ + (51.2507, 6.9751), # Mettmann + (51.2965, 6.8494), # Ratingen + (51.3398, 7.0438), # Velbert +] + +FIRE_POINTS = [ + (51.2518, 6.9800), + (51.2937, 6.8568), + (51.3314, 7.0540), +] + +HOSPITAL_POINTS = [ + (51.2556, 6.9723), + (51.2891, 6.8457), + (51.3321, 7.0403), +] + +INDUSTRIAL_ZONES = [ + (51.2348, 6.9902), + (51.3022, 6.8340), +] +COMMERCIAL_ZONES = [ + (51.2512, 6.9878), + (51.2934, 6.8541), +] +NATURE_ZONES = [ + (51.2715, 6.9440), + (51.3188, 7.0274), +] +PARKING_ZONES = [ + (51.2499, 6.9831), +] +MAIN_ROAD_POINTS = [ + (51.2524, 6.9921), + (51.2951, 6.8615), +] + + +@dataclass +class Factor: + key: str + label: str + points: float + + +def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + r = 6371000 + p1 = math.radians(lat1) + p2 = math.radians(lat2) + dp = math.radians(lat2 - lat1) + dl = math.radians(lon2 - lon1) + a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 + return 2 * r * math.asin(math.sqrt(a)) + + +def nearest_distance_m(lat: float, lon: float, points: list[tuple[float, float]]) -> int: + return int(min(haversine_m(lat, lon, p_lat, p_lon) for p_lat, p_lon in points)) + + +def classify_area(lat: float, lon: float) -> str: + candidates = [ + ("industrial", nearest_distance_m(lat, lon, INDUSTRIAL_ZONES)), + ("commercial", nearest_distance_m(lat, lon, COMMERCIAL_ZONES)), + ("nature", nearest_distance_m(lat, lon, NATURE_ZONES)), + ("parking", nearest_distance_m(lat, lon, PARKING_ZONES)), + ] + best_type, best_dist = min(candidates, key=lambda x: x[1]) + if best_dist <= 400: + return best_type + return "residential" + + +def classify_road(lat: float, lon: float) -> str: + if nearest_distance_m(lat, lon, MAIN_ROAD_POINTS) < 250: + return "primary" + return "residential" + + +def spot_id_for(lat: float, lon: float) -> str: + key = f"{lat:.4f}:{lon:.4f}" + return str(uuid.uuid5(uuid.NAMESPACE_DNS, f"staysense:{key}")) + + +def score_area_modifier(area_type: str) -> Factor: + mapping = { + "residential": Factor("area", "Wohngebiet", -10), + "industrial": Factor("area", "Industriegebiet", 10), + "commercial": Factor("area", "Gewerbegebiet", 6), + "parking": Factor("area", "Parkplatzumfeld", -5), + "nature": Factor("area", "Naturnah", 8), + } + return mapping.get(area_type, Factor("area", "Umgebung", 0)) + + +def night_window_for(reference: dt.datetime) -> tuple[dt.datetime, dt.datetime]: + ref = reference.replace(second=0, microsecond=0) + if ref.hour >= 22: + start = ref.replace(hour=22, minute=0) + elif ref.hour < 6: + start = (ref - dt.timedelta(days=1)).replace(hour=22, minute=0) + else: + start = ref.replace(hour=22, minute=0) + end = start + dt.timedelta(hours=8) + return start, end + + +def weekend_or_holiday(night_start: dt.datetime) -> bool: + weekday = night_start.weekday() # 0=Mon, 4=Fri + if weekday in (4, 5): + return True + if night_start.date() in NRW_HOLIDAYS_2026: + return True + if (night_start + dt.timedelta(days=1)).date() in NRW_HOLIDAYS_2026: + return True + return False + + +def decay(age_days: float, half_life_days: float) -> float: + if age_days <= 0: + return 1.0 + return 0.5 ** (age_days / half_life_days) + + +def clamp_score(value: float) -> int: + return max(0, min(100, int(round(value)))) + + +def ampel(score: int) -> str: + if score >= 70: + return "green" + if score >= 45: + return "yellow" + return "red" diff --git a/backend/server.py b/backend/server.py new file mode 100644 index 0000000..86a56c9 --- /dev/null +++ b/backend/server.py @@ -0,0 +1,512 @@ +import datetime as dt +import hashlib +import hmac +import json +import os +import uuid +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlparse + +from db import get_conn, init_db +from score_engine import ( + ampel, + clamp_score, + classify_area, + classify_road, + decay, + haversine_m, + nearest_distance_m, + night_window_for, + score_area_modifier, + spot_id_for, + weekend_or_holiday, +) + +HOST = "127.0.0.1" +PORT = 8787 + +DEFAULT_SALT = "change-me-in-production" +SERVER_SALT = os.environ.get("STAYSENSE_SERVER_SALT", DEFAULT_SALT) +SIGNAL_COOLDOWN_HOURS = int(os.environ.get("STAYSENSE_SIGNAL_COOLDOWN_HOURS", "24")) + +FALLBACK_POLICE_POINTS = [(51.2507, 6.9751), (51.2965, 6.8494), (51.3398, 7.0438)] +FALLBACK_FIRE_POINTS = [(51.2518, 6.9800), (51.2937, 6.8568), (51.3314, 7.0540)] +FALLBACK_HOSPITAL_POINTS = [(51.2556, 6.9723), (51.2891, 6.8457), (51.3321, 7.0403)] + + +def utc_now() -> dt.datetime: + return dt.datetime.now(dt.timezone.utc) + + +def parse_iso8601(value: str | None) -> dt.datetime: + if not value: + return utc_now() + parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + return parsed.replace(tzinfo=dt.timezone.utc) + return parsed.astimezone(dt.timezone.utc) + + +def to_iso(value: dt.datetime) -> str: + return value.astimezone(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def json_response(handler: BaseHTTPRequestHandler, status: int, payload: dict) -> None: + raw = json.dumps(payload, ensure_ascii=True).encode("utf-8") + handler.send_response(status) + handler.send_header("Content-Type", "application/json; charset=utf-8") + handler.send_header("Content-Length", str(len(raw))) + handler.send_header("Cache-Control", "no-store") + handler.end_headers() + handler.wfile.write(raw) + + +def read_json(handler: BaseHTTPRequestHandler) -> dict: + length = int(handler.headers.get("Content-Length", "0")) + if length <= 0: + return {} + body = handler.rfile.read(length) + return json.loads(body.decode("utf-8")) + + +def hashed_device(device_token: str) -> str: + return hmac.new( + key=SERVER_SALT.encode("utf-8"), + msg=device_token.encode("utf-8"), + digestmod=hashlib.sha256, + ).hexdigest() + + +def fetch_points(sql: str, params: tuple) -> list[tuple[float, float]]: + with get_conn() as conn: + rows = conn.execute(sql, params).fetchall() + return [(float(r["lat"]), float(r["lon"])) for r in rows] + + +def nearest_from_db( + lat: float, lon: float, table: str, type_col: str, value: str, fallback_points: list[tuple[float, float]] +) -> tuple[int, bool]: + rows = fetch_points( + f"SELECT lat, lon FROM {table} WHERE {type_col} = ?", + (value,), + ) + if rows: + return int(min(haversine_m(lat, lon, x, y) for x, y in rows)), False + if fallback_points: + return nearest_distance_m(lat, lon, fallback_points), True + return 5000, True + + +def area_from_db(lat: float, lon: float) -> str: + with get_conn() as conn: + rows = conn.execute("SELECT zone_type, lat, lon FROM osm_zone").fetchall() + + if not rows: + return classify_area(lat, lon) + + best_type = "residential" + best_dist = 999999.0 + for row in rows: + dist = haversine_m(lat, lon, float(row["lat"]), float(row["lon"])) + if dist < best_dist: + best_dist = dist + best_type = row["zone_type"] + + return best_type if best_dist <= 500 else "residential" + + +def road_from_db(lat: float, lon: float) -> str: + with get_conn() as conn: + rows = conn.execute("SELECT road_type, lat, lon FROM osm_road").fetchall() + + if not rows: + return classify_road(lat, lon) + + best_type = "unknown" + best_dist = 999999.0 + for row in rows: + dist = haversine_m(lat, lon, float(row["lat"]), float(row["lon"])) + if dist < best_dist: + best_dist = dist + best_type = row["road_type"] + + return best_type if best_dist <= 300 else "unknown" + + +def ensure_spot(lat: float, lon: float, now_iso: str) -> dict: + s_id = spot_id_for(lat, lon) + with get_conn() as conn: + row = conn.execute("SELECT * FROM spot WHERE id = ?", (s_id,)).fetchone() + if row: + return dict(row) + + area_type = area_from_db(lat, lon) + road_type = road_from_db(lat, lon) + + police_d, police_fallback = nearest_from_db(lat, lon, "osm_poi", "poi_type", "police", FALLBACK_POLICE_POINTS) + fire_d, fire_fallback = nearest_from_db(lat, lon, "osm_poi", "poi_type", "fire", FALLBACK_FIRE_POINTS) + hosp_d, hosp_fallback = nearest_from_db(lat, lon, "osm_poi", "poi_type", "hospital", FALLBACK_HOSPITAL_POINTS) + + conn.execute( + """ + INSERT INTO spot ( + id, lat, lon, osm_area_type, road_type, + distance_police_m, distance_fire_m, distance_hospital_m, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (s_id, lat, lon, area_type, road_type, police_d, fire_d, hosp_d, now_iso, now_iso), + ) + + return { + "id": s_id, + "lat": lat, + "lon": lon, + "osm_area_type": area_type, + "road_type": road_type, + "distance_police_m": police_d, + "distance_fire_m": fire_d, + "distance_hospital_m": hosp_d, + "used_fallback_pois": police_fallback or fire_fallback or hosp_fallback, + } + + +def collect_local_event_factors(lat: float, lon: float, night_end: dt.datetime) -> list[dict]: + next_morning_start = night_end + next_morning_end = night_end + dt.timedelta(hours=4) + + with get_conn() as conn: + rows = conn.execute( + """ + SELECT event_type, risk_modifier, source, lat, lon, start_datetime, end_datetime + FROM open_data_event + WHERE start_datetime <= ? + AND end_datetime >= ? + """, + (to_iso(next_morning_end), to_iso(next_morning_start)), + ).fetchall() + + factors = [] + seen = set() + for row in rows: + distance = haversine_m(lat, lon, float(row["lat"]), float(row["lon"])) + if distance > 1000: + continue + + event_type = row["event_type"] + label = { + "waste": "Muellabfuhr am Morgen", + "market": "Marktbetrieb am Morgen", + "event": "Lokale Veranstaltung", + "construction": "Baustelle", + }.get(event_type, "Lokales Ereignis") + dedupe_key = ( + event_type, + row["risk_modifier"], + row["start_datetime"] if "start_datetime" in row.keys() else "", + row["end_datetime"] if "end_datetime" in row.keys() else "", + round(float(row["lat"]), 4), + round(float(row["lon"]), 4), + ) + if dedupe_key in seen: + continue + seen.add(dedupe_key) + + factors.append( + { + "key": f"event_{event_type}", + "label": label, + "points": float(row["risk_modifier"]), + "source": row["source"], + } + ) + return factors + + +def collect_community_factors(spot_id: str, at_time: dt.datetime) -> list[dict]: + max_window = at_time - dt.timedelta(days=30) + with get_conn() as conn: + rows = conn.execute( + """ + SELECT signal_type, timestamp + FROM community_signal + WHERE spot_id = ? + AND timestamp >= ? + """, + (spot_id, to_iso(max_window)), + ).fetchall() + + buckets = { + "knock": {"base": -25.0, "half_life": 10.0, "window": 30, "label": "Klopfen gemeldet"}, + "noise": {"base": -15.0, "half_life": 7.0, "window": 14, "label": "Laerm gemeldet"}, + "calm": {"base": 10.0, "half_life": 7.0, "window": 14, "label": "Ruhig gemeldet"}, + "police": {"base": -18.0, "half_life": 10.0, "window": 30, "label": "Polizei-Einsatz gemeldet"}, + } + + by_type: dict[str, float] = {key: 0.0 for key in buckets} + for row in rows: + signal_type = row["signal_type"] + if signal_type not in buckets: + continue + cfg = buckets[signal_type] + sig_ts = parse_iso8601(row["timestamp"]) + age_days = max(0.0, (at_time - sig_ts).total_seconds() / 86400.0) + if age_days > cfg["window"]: + continue + by_type[signal_type] += cfg["base"] * decay(age_days, cfg["half_life"]) + + out = [] + for signal_type, points in by_type.items(): + if abs(points) < 0.5: + continue + label = buckets[signal_type]["label"] + out.append( + { + "key": f"community_{signal_type}", + "label": label, + "points": points, + "source": "community", + } + ) + return out + + +def data_source_meta() -> list[dict]: + with get_conn() as conn: + rows = conn.execute( + "SELECT source_name, imported_at, record_count, notes FROM data_source_state ORDER BY imported_at DESC" + ).fetchall() + return [dict(row) for row in rows] + + +def source_health(sources: list[dict], now: dt.datetime) -> dict: + if not sources: + return {"freshest_age_hours": None, "stalest_age_hours": None, "stale_sources": [], "has_data": False} + + ages = [] + stale_sources = [] + for src in sources: + try: + age_h = (now - parse_iso8601(src["imported_at"])).total_seconds() / 3600.0 + except Exception: + continue + age_h = max(0.0, age_h) + ages.append((src["source_name"], age_h)) + if age_h > 24: + stale_sources.append(src["source_name"]) + + if not ages: + return {"freshest_age_hours": None, "stalest_age_hours": None, "stale_sources": [], "has_data": False} + + freshest = min(age for _, age in ages) + stalest = max(age for _, age in ages) + return { + "freshest_age_hours": round(freshest, 2), + "stalest_age_hours": round(stalest, 2), + "stale_sources": stale_sources, + "has_data": True, + } + + +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) + sources = data_source_meta() + health = source_health(sources, utc_now()) + + night_start, night_end = night_window_for(at_time) + factors = [] + + area_factor = score_area_modifier(spot["osm_area_type"]) + factors.append({"key": area_factor.key, "label": area_factor.label, "points": area_factor.points, "source": "osm"}) + + if spot["distance_police_m"] < 200: + factors.append({"key": "dist_police", "label": "Polizei in <200m", "points": -15.0, "source": "osm"}) + if spot["distance_hospital_m"] < 200: + factors.append({"key": "dist_hospital", "label": "Krankenhaus in <200m", "points": -10.0, "source": "osm"}) + + if weekend_or_holiday(night_start): + factors.append({"key": "time_weekend", "label": "Wochenende/Feiertag", "points": -10.0, "source": "time"}) + else: + factors.append({"key": "time_weekday", "label": "Werktagnacht", "points": 5.0, "source": "time"}) + + factors.extend(collect_local_event_factors(lat, lon, night_end)) + factors.extend(collect_community_factors(spot["id"], at_time)) + + 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] + reasons = [f"{item['label']} ({item['points']:+.0f})" for item in top_reasons] + + return { + "spot_id": spot["id"], + "score": score, + "ampel": ampel(score), + "reasons": reasons, + "factors": top_reasons, + "night_window": { + "start": to_iso(night_start), + "end": to_iso(night_end), + }, + "meta": { + "data_updated_at": now_iso, + "region": "DE-NW (Pilot: Kreis Mettmann)", + "attribution": "Kartendaten: OpenStreetMap-Mitwirkende (ODbL)", + "sources": sources, + "health": health, + "used_fallback_pois": bool(spot.get("used_fallback_pois", False)), + }, + } + + +def handle_score(handler: BaseHTTPRequestHandler, query: dict[str, list[str]]) -> None: + try: + lat = float(query.get("lat", [""])[0]) + lon = float(query.get("lon", [""])[0]) + at = parse_iso8601(query.get("at", [""])[0]) + except Exception: + json_response(handler, HTTPStatus.BAD_REQUEST, {"error": "invalid_query"}) + return + + if not (47.0 <= lat <= 55.5 and 5.0 <= lon <= 16.0): + json_response(handler, HTTPStatus.BAD_REQUEST, {"error": "lat_lon_out_of_bounds"}) + return + + payload = compute_score_payload(lat, lon, at) + json_response(handler, HTTPStatus.OK, payload) + + +def handle_signal(handler: BaseHTTPRequestHandler) -> None: + try: + body = read_json(handler) + except Exception: + json_response(handler, HTTPStatus.BAD_REQUEST, {"error": "invalid_json"}) + return + + spot_id = body.get("spot_id") + signal_type = body.get("signal_type") + device_token = body.get("device_token") + timestamp = parse_iso8601(body.get("timestamp")) + + if not isinstance(spot_id, str) or not spot_id: + json_response(handler, HTTPStatus.BAD_REQUEST, {"error": "spot_id_required"}) + return + if signal_type not in {"calm", "noise", "knock", "police"}: + json_response(handler, HTTPStatus.BAD_REQUEST, {"error": "invalid_signal_type"}) + return + if not isinstance(device_token, str) or len(device_token) < 16: + json_response(handler, HTTPStatus.BAD_REQUEST, {"error": "invalid_device_token"}) + return + + hashed = hashed_device(device_token) + now = utc_now() + if timestamp > now + dt.timedelta(minutes=5): + timestamp = now + + cooldown_start = timestamp - dt.timedelta(hours=SIGNAL_COOLDOWN_HOURS) + + with get_conn() as conn: + spot_exists = conn.execute("SELECT 1 FROM spot WHERE id = ?", (spot_id,)).fetchone() + if not spot_exists: + json_response(handler, HTTPStatus.BAD_REQUEST, {"error": "unknown_spot_id"}) + return + + latest = conn.execute( + """ + SELECT timestamp + FROM community_signal + WHERE spot_id = ? + AND hashed_device = ? + AND timestamp >= ? + ORDER BY timestamp DESC + LIMIT 1 + """, + (spot_id, hashed, to_iso(cooldown_start)), + ).fetchone() + + if latest: + next_allowed = parse_iso8601(latest["timestamp"]) + dt.timedelta(hours=SIGNAL_COOLDOWN_HOURS) + json_response( + handler, + HTTPStatus.TOO_MANY_REQUESTS, + { + "accepted": False, + "error": "cooldown_active", + "next_allowed_at": to_iso(next_allowed), + }, + ) + return + + day_bucket = timestamp.date().isoformat() + try: + conn.execute( + """ + INSERT INTO community_signal ( + id, spot_id, signal_type, hashed_device, timestamp, day_bucket + ) VALUES (?, ?, ?, ?, ?, ?) + """, + ( + str(uuid.uuid4()), + spot_id, + signal_type, + hashed, + to_iso(timestamp), + day_bucket, + ), + ) + except Exception: + json_response( + handler, + HTTPStatus.TOO_MANY_REQUESTS, + { + "accepted": False, + "error": "daily_limit", + }, + ) + return + + json_response( + handler, + HTTPStatus.CREATED, + { + "accepted": True, + "cooldown_hours": SIGNAL_COOLDOWN_HOURS, + }, + ) + + +class StaySenseHandler(BaseHTTPRequestHandler): + def log_message(self, format: str, *args) -> None: + return + + def do_GET(self) -> None: + parsed = urlparse(self.path) + if parsed.path == "/health": + sources = data_source_meta() + json_response(self, HTTPStatus.OK, {"status": "ok", "sources": sources, "health": source_health(sources, utc_now())}) + return + if parsed.path == "/spot/score": + query = parse_qs(parsed.query) + handle_score(self, query) + return + json_response(self, HTTPStatus.NOT_FOUND, {"error": "not_found"}) + + def do_POST(self) -> None: + parsed = urlparse(self.path) + if parsed.path == "/spot/signal": + handle_signal(self) + return + json_response(self, HTTPStatus.NOT_FOUND, {"error": "not_found"}) + + +def main() -> None: + init_db() + server = ThreadingHTTPServer((HOST, PORT), StaySenseHandler) + print(f"StaySense API listening on http://{HOST}:{PORT}") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/backend/tests/test_open_data_connector.py b/backend/tests/test_open_data_connector.py new file mode 100644 index 0000000..06e3c30 --- /dev/null +++ b/backend/tests/test_open_data_connector.py @@ -0,0 +1,21 @@ +import unittest +from pathlib import Path + +from open_data_connector import import_from_config + + +class OpenDataConnectorTests(unittest.TestCase): + def test_import_from_config_has_stats(self) -> None: + cfg = Path(__file__).resolve().parents[2] / "docs" / "open_data_sources.json" + result = import_from_config(cfg, prune_legacy=True) + self.assertIn("imported", result) + self.assertIn("pruned", result) + self.assertGreaterEqual(len(result["imported"]), 1) + + imported = result["imported"][0] + self.assertIn("stats", imported) + self.assertIn("accepted", imported["stats"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_score_engine.py b/backend/tests/test_score_engine.py new file mode 100644 index 0000000..7daa7a4 --- /dev/null +++ b/backend/tests/test_score_engine.py @@ -0,0 +1,25 @@ +import datetime as dt +import unittest + +from score_engine import ampel, clamp_score, night_window_for + + +class ScoreEngineTests(unittest.TestCase): + def test_ampel_thresholds(self) -> None: + self.assertEqual(ampel(70), "green") + self.assertEqual(ampel(45), "yellow") + self.assertEqual(ampel(10), "red") + + def test_clamp_score(self) -> None: + self.assertEqual(clamp_score(-20), 0) + self.assertEqual(clamp_score(120), 100) + + def test_night_window(self) -> None: + ref = dt.datetime(2026, 2, 15, 23, 30, tzinfo=dt.timezone.utc) + start, end = night_window_for(ref) + self.assertEqual(start.hour, 22) + self.assertEqual(end.hour, 6) + + +if __name__ == "__main__": + unittest.main() diff --git a/deploy/nginx/staysense.conf b/deploy/nginx/staysense.conf new file mode 100644 index 0000000..28fc44a --- /dev/null +++ b/deploy/nginx/staysense.conf @@ -0,0 +1,18 @@ +server { + listen 80; + server_name staysense.example.com; + + root /opt/staysense/src; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://127.0.0.1:8787/; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/deploy/systemd/staysense-api.service b/deploy/systemd/staysense-api.service new file mode 100644 index 0000000..b31df62 --- /dev/null +++ b/deploy/systemd/staysense-api.service @@ -0,0 +1,18 @@ +[Unit] +Description=StaySense API Service +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=/opt/staysense/backend +Environment=STAYSENSE_SERVER_SALT=CHANGE_ME +Environment=STAYSENSE_SIGNAL_COOLDOWN_HOURS=24 +ExecStart=/usr/bin/python3 /opt/staysense/backend/server.py +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/staysense-import.service b/deploy/systemd/staysense-import.service new file mode 100644 index 0000000..21a4d05 --- /dev/null +++ b/deploy/systemd/staysense-import.service @@ -0,0 +1,11 @@ +[Unit] +Description=StaySense Data Import Job +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +User=www-data +Group=www-data +WorkingDirectory=/opt/staysense/backend +ExecStart=/usr/bin/python3 /opt/staysense/backend/run_import_jobs.py --config /opt/staysense/docs/open_data_sources.json --prune-legacy diff --git a/deploy/systemd/staysense-import.timer b/deploy/systemd/staysense-import.timer new file mode 100644 index 0000000..643963c --- /dev/null +++ b/deploy/systemd/staysense-import.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Run StaySense import every 6 hours + +[Timer] +OnBootSec=5min +OnUnitActiveSec=6h +Unit=staysense-import.service +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..454afae --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,102 @@ +# StaySense Deployment Guide (Linux + Nginx) + +## 1. Voraussetzungen + +- Ubuntu/Debian Server mit sudo +- Domain (optional, empfohlen) +- Python 3.10+ +- Nginx +- systemd + +Install: + +```bash +sudo apt update +sudo apt install -y python3 nginx +``` + +## 2. Code bereitstellen + +```bash +sudo mkdir -p /opt/staysense +sudo chown -R $USER:$USER /opt/staysense +git clone /opt/staysense +``` + +## 3. Initialisierung + +```bash +cd /opt/staysense/backend +python3 -c "from db import init_db; init_db()" +python3 import_osm_overpass.py +python3 run_import_jobs.py --config ../docs/open_data_sources.json --prune-legacy +``` + +## 4. API als Service starten + +1. Service-Datei kopieren: + +```bash +sudo cp /opt/staysense/deploy/systemd/staysense-api.service /etc/systemd/system/ +``` + +2. Secret setzen (Datei anpassen): + +```bash +sudo nano /etc/systemd/system/staysense-api.service +# STAYSENSE_SERVER_SALT=... setzen +``` + +3. Aktivieren: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now staysense-api.service +sudo systemctl status staysense-api.service +``` + +## 5. Import-Timer aktivieren + +```bash +sudo cp /opt/staysense/deploy/systemd/staysense-import.service /etc/systemd/system/ +sudo cp /opt/staysense/deploy/systemd/staysense-import.timer /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now staysense-import.timer +sudo systemctl list-timers | grep staysense +``` + +## 6. Nginx konfigurieren + +```bash +sudo cp /opt/staysense/deploy/nginx/staysense.conf /etc/nginx/sites-available/staysense +sudo ln -s /etc/nginx/sites-available/staysense /etc/nginx/sites-enabled/staysense +sudo nginx -t +sudo systemctl reload nginx +``` + +## 7. API-Route im Frontend anpassen + +Fuer Reverse Proxy `/api` kann in `src/index.html` vor `app.js` gesetzt werden: + +```html + +``` + +Danach Nginx reloaden. + +## 8. HTTPS (empfohlen) + +```bash +sudo apt install -y certbot python3-certbot-nginx +sudo certbot --nginx -d staysense.example.com +``` + +## 9. Betrieb / Checks + +```bash +curl -s http://127.0.0.1:8787/health +sudo journalctl -u staysense-api.service -f +sudo journalctl -u staysense-import.service -n 100 +``` diff --git a/docs/GITHUB_PUBLISH.md b/docs/GITHUB_PUBLISH.md new file mode 100644 index 0000000..23bc254 --- /dev/null +++ b/docs/GITHUB_PUBLISH.md @@ -0,0 +1,27 @@ +# GitHub Publish Guide + +## 1. Repository erstellen + +- Neues GitHub Repo erstellen, z. B. `staysense-mvp` +- Private oder Public nach Bedarf + +## 2. Remote setzen + +```bash +cd StaySense +git remote add origin git@github.com:/.git +# alternativ HTTPS: +# git remote add origin https://github.com//.git +``` + +## 3. Push + +```bash +git push -u origin main +``` + +## 4. Empfohlene Repo-Settings + +- Branch Protection fuer `main` +- Issues/Projects aktivieren +- Secrets fuer Deployment (falls CI/CD) diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..fd5bfcf --- /dev/null +++ b/docs/IMPLEMENTATION_PLAN.md @@ -0,0 +1,79 @@ +# StaySense Umsetzungsplan (MVP NRW) + +## Ziel + +Entscheidung in <10 Sekunden: Ist ein Spot heute Nacht (22-06) voraussichtlich ruhig? + +## Scope + +- Region: NRW, Pilot Kreis Mettmann +- Plattform im MVP: Web-App mit iOS-tauglichem Verhalten (Offline-Queue/Cache) +- Kein Account-System + +## Architektur + +- Frontend: Vanilla HTML/CSS/JS (`src/`) +- Backend: Python Standardbibliothek + SQLite (`backend/`) +- Daten: `data/staysense.db` + +## API (MVP) + +- `GET /spot/score?lat=..&lon=..&at=ISO8601` + - liefert `score`, `ampel`, `reasons`, `night_window`, `meta` +- `POST /spot/signal` + - Body: `spot_id`, `signal_type`, `device_token`, `timestamp` + - erzwingt 1 Signal pro `(spot, device)` in 24h + +## Datenpipeline + +- OSM Import (Overpass): + - Tabellen: `osm_poi`, `osm_zone`, `osm_road` + - Script: `backend/import_osm_overpass.py` +- OpenData Connector Layer: + - Script: `backend/open_data_connector.py` + - Konfig: `docs/open_data_sources.json` + - Formate: CSV + JSON +- Job Runner: + - Script: `backend/run_import_jobs.py` + - Modi: einmalig (`once`) oder periodisch (`daemon`) + - Optionales Legacy-Pruning: `--prune-legacy` + +## Datenmodell + +- `spot` + - Standortmetadaten inkl. OSM-Typ und Distanzmetriken +- `community_signal` + - Signale mit `hashed_device`, ohne PII +- `open_data_event` + - lokale Risiko-Ereignisse mit Zeitfenster +- `data_source_state` + - Importstand/Frische je Datenquelle + +## Score Engine v0.1 + +- Startwert: `100` +- Modifikatoren: + - Umgebungstyp (z. B. residential -10, industrial +10) + - Distanz zu Polizei/Krankenhaus + - Zeitlogik (Wochenende/Feiertag -10, Werktagnacht +5) + - lokale Events (z. B. Muellabfuhr -20) + - Community-Signale mit Zeit-Decay (`calm`, `noise`, `knock`, `police`) +- Ausgabe: + - Score 0-100 + - Ampel + - Top-2 bis Top-4 Gruende + - Source-Health in `meta.health` (Freshness/Fallback-Info) + +## Datenschutz / Sicherheit + +- Local device token (UUIDv4) nur auf Client +- Backend speichert nur HMAC-Hash +- Kein Fingerprinting +- Kein Login +- HTTPS-only fuer Produktion + +## Naechste Schritte + +1. Reale NRW/Kommunal-URLs in `open_data_sources.json` aktivieren +2. Health-Checks + Alerting fuer fehlgeschlagene Importjobs +3. Postgres + PostGIS Migration diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md new file mode 100644 index 0000000..dd565f8 --- /dev/null +++ b/docs/OPERATIONS.md @@ -0,0 +1,41 @@ +# Operations Runbook + +## Wichtige Befehle + +API neu starten: + +```bash +sudo systemctl restart staysense-api.service +``` + +Import manuell ausfuehren: + +```bash +sudo systemctl start staysense-import.service +``` + +Service-Logs: + +```bash +sudo journalctl -u staysense-api.service -f +sudo journalctl -u staysense-import.service -f +``` + +Health check: + +```bash +curl -s http://127.0.0.1:8787/health +``` + +## Backup + +```bash +cp /opt/staysense/data/staysense.db /opt/staysense/data/staysense.db.bak +``` + +## Restore + +```bash +cp /opt/staysense/data/staysense.db.bak /opt/staysense/data/staysense.db +sudo systemctl restart staysense-api.service +``` diff --git a/docs/open_data_events_template.csv b/docs/open_data_events_template.csv new file mode 100644 index 0000000..73c4940 --- /dev/null +++ b/docs/open_data_events_template.csv @@ -0,0 +1,3 @@ +lat,lon,event_type,start_datetime,end_datetime,risk_modifier,source +51.2502,6.9735,waste,2026-02-16T06:00:00Z,2026-02-16T08:00:00Z,-20,stadt_mettmann_abfall +51.2960,6.8500,market,2026-02-16T05:30:00Z,2026-02-16T10:30:00Z,-12,stadt_ratingen_markt diff --git a/docs/open_data_sources.json b/docs/open_data_sources.json new file mode 100644 index 0000000..f508c14 --- /dev/null +++ b/docs/open_data_sources.json @@ -0,0 +1,42 @@ +{ + "sources": [ + { + "id": "local_template_csv", + "enabled": true, + "format": "csv", + "file": "open_data_events_template.csv", + "source_name": "stadt_mettmann_template", + "field_map": { + "lat": "lat", + "lon": "lon", + "event_type": "event_type", + "start_datetime": "start_datetime", + "end_datetime": "end_datetime", + "risk_modifier": "risk_modifier" + } + }, + { + "id": "nrw_portal_placeholder_json", + "enabled": false, + "format": "json", + "url": "https://example-opendata.nrw.example/api/events.json", + "json_path": "items", + "source_name": "open_data_nrw", + "field_map": { + "external_id": "id", + "lat": "latitude", + "lon": "longitude", + "event_type": "category", + "start_datetime": "start", + "end_datetime": "end", + "risk_modifier": "risk" + }, + "event_type_map": { + "abfall": "waste", + "markt": "market", + "veranstaltung": "event", + "baustelle": "construction" + } + } + ] +} diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..8a2ad59 --- /dev/null +++ b/src/app.js @@ -0,0 +1,306 @@ +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", + }); +} diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..d11d3b6 --- /dev/null +++ b/src/index.html @@ -0,0 +1,86 @@ + + + + + + StaySense NRW MVP + + + +
+
+ +
+

StaySense

+

Kreis Mettmann Pilot: ruhige Nacht in weniger als 10 Sekunden bewerten.

+ Netzwerkstatus wird geprueft ... + Datenstand: - +
+ +
+
+

Standort

+
+ + +
+ +
+ + +
+ +

Night Hours im MVP fix: 22:00 bis 06:00.

+
+ +
+

Quick Decision

+
+
+
--
+
-
+
+

Bezug: Heute Nacht 22:00-06:00

+
    +
  • Noch keine Daten geladen.
  • +
+
+ +

Community Signal

+
+ + + + +
+ Noch kein Signal gesendet. + Queue: 0 ausstehend +
+ +
+

Settings

+ +

Offline werden Signale gequeued und spaeter gesendet.

+ +

Rechtliches

+ + +
+
+ + + + diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..a6d4209 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,232 @@ +:root { + --bg: #f3f7f6; + --ink: #15232a; + --muted: #4f606b; + --panel: #ffffff; + --line: #d4dfe5; + --green: #007a4d; + --yellow: #b57900; + --red: #b63133; + --brand: #006680; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + font-family: "Avenir Next", "Segoe UI", sans-serif; + color: var(--ink); + background: linear-gradient(160deg, #f7fbfa 0%, #edf3fb 100%); +} + +.background { + position: fixed; + z-index: -1; + border-radius: 999px; + filter: blur(40px); + opacity: 0.28; +} + +.haze-a { + width: 270px; + height: 270px; + background: #54d99c; + top: -40px; + right: 5%; +} + +.haze-b { + width: 240px; + height: 240px; + background: #5eb4ef; + bottom: -30px; + left: 3%; +} + +.top { + text-align: center; + padding: 24px 16px 8px; +} + +.top h1 { + margin: 0; + font-size: clamp(1.9rem, 4vw, 3rem); +} + +.top p { + margin: 8px auto; + max-width: 760px; + color: var(--muted); +} + +#network-status { + color: var(--brand); + display: block; +} + +#data-status { + display: block; + color: #486270; +} + +.grid { + max-width: 1120px; + margin: 8px auto 32px; + padding: 0 14px; + display: grid; + gap: 12px; + grid-template-columns: 1fr; +} + +.panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 14px; + padding: 14px; + box-shadow: 0 8px 30px rgba(19, 45, 56, 0.06); +} + +h2, +h3 { + margin: 0 0 10px; +} + +.field-grid { + display: grid; + gap: 10px; + grid-template-columns: 1fr; +} + +label { + display: grid; + gap: 4px; + color: var(--muted); + font-size: 0.9rem; +} + +input, +button { + font: inherit; +} + +input { + border: 1px solid var(--line); + border-radius: 10px; + padding: 9px; + background: #fff; +} + +.button-row { + margin-top: 10px; + display: grid; + gap: 8px; +} + +.btn { + border: none; + border-radius: 10px; + padding: 10px 12px; + background: linear-gradient(120deg, #006680, #1689a8); + color: #fff; + cursor: pointer; +} + +.btn.secondary { + background: linear-gradient(120deg, #4c5e67, #6f8089); +} + +.btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.hint, +.small { + color: var(--muted); + font-size: 0.88rem; +} + +.decision { + border: 1px solid var(--line); + border-radius: 12px; + padding: 12px; +} + +.score-wrap { + display: flex; + gap: 10px; + align-items: baseline; +} + +.score { + font-size: 2.3rem; + font-weight: 700; +} + +.ampel { + font-size: 1rem; + padding: 4px 10px; + border-radius: 999px; + color: #fff; + background: #4b5f6b; +} + +.ampel.green { + background: var(--green); +} + +.ampel.yellow { + background: var(--yellow); +} + +.ampel.red { + background: var(--red); +} + +.reasons { + margin: 10px 0 0; + padding-left: 18px; +} + +.signal-grid { + display: grid; + gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-bottom: 6px; +} + +.toggle { + display: flex; + align-items: center; + gap: 8px; +} + +.legal { + margin: 8px 0; + padding-left: 18px; +} + +.legal a { + color: var(--brand); +} + +.legal-output { + border: 1px dashed var(--line); + border-radius: 10px; + padding: 10px; + color: var(--muted); + min-height: 42px; +} + +@media (min-width: 920px) { + .grid { + grid-template-columns: 1fr 1.2fr 0.9fr; + } + + .field-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +}