feat: implement StaySense MVP backend, frontend, imports, and deployment docs
This commit is contained in:
commit
902988276c
24 changed files with 2536 additions and 0 deletions
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Local runtime data
|
||||||
|
data/*.db
|
||||||
|
data/*.sqlite
|
||||||
|
|
||||||
|
# Local logs / temp
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
92
README.md
Normal file
92
README.md
Normal file
|
|
@ -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":"<ID>","signal_type":"noise","device_token":"<uuid-v4>","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`
|
||||||
122
backend/db.py
Normal file
122
backend/db.py
Normal file
|
|
@ -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);
|
||||||
|
"""
|
||||||
|
)
|
||||||
52
backend/import_open_data_events.py
Normal file
52
backend/import_open_data_events.py
Normal file
|
|
@ -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()
|
||||||
209
backend/import_osm_overpass.py
Normal file
209
backend/import_osm_overpass.py
Normal file
|
|
@ -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()
|
||||||
317
backend/open_data_connector.py
Normal file
317
backend/open_data_connector.py
Normal file
|
|
@ -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),
|
||||||
|
}
|
||||||
43
backend/run_import_jobs.py
Normal file
43
backend/run_import_jobs.py
Normal file
|
|
@ -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()
|
||||||
153
backend/score_engine.py
Normal file
153
backend/score_engine.py
Normal file
|
|
@ -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"
|
||||||
512
backend/server.py
Normal file
512
backend/server.py
Normal file
|
|
@ -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()
|
||||||
21
backend/tests/test_open_data_connector.py
Normal file
21
backend/tests/test_open_data_connector.py
Normal file
|
|
@ -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()
|
||||||
25
backend/tests/test_score_engine.py
Normal file
25
backend/tests/test_score_engine.py
Normal file
|
|
@ -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()
|
||||||
18
deploy/nginx/staysense.conf
Normal file
18
deploy/nginx/staysense.conf
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
deploy/systemd/staysense-api.service
Normal file
18
deploy/systemd/staysense-api.service
Normal file
|
|
@ -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
|
||||||
11
deploy/systemd/staysense-import.service
Normal file
11
deploy/systemd/staysense-import.service
Normal file
|
|
@ -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
|
||||||
11
deploy/systemd/staysense-import.timer
Normal file
11
deploy/systemd/staysense-import.timer
Normal file
|
|
@ -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
|
||||||
102
docs/DEPLOYMENT.md
Normal file
102
docs/DEPLOYMENT.md
Normal file
|
|
@ -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 <REPO_URL> /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
|
||||||
|
<script>
|
||||||
|
window.STAYSENSE_API_BASE = "/api";
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
27
docs/GITHUB_PUBLISH.md
Normal file
27
docs/GITHUB_PUBLISH.md
Normal file
|
|
@ -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:<ORG_OR_USER>/<REPO>.git
|
||||||
|
# alternativ HTTPS:
|
||||||
|
# git remote add origin https://github.com/<ORG_OR_USER>/<REPO>.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)
|
||||||
79
docs/IMPLEMENTATION_PLAN.md
Normal file
79
docs/IMPLEMENTATION_PLAN.md
Normal file
|
|
@ -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
|
||||||
41
docs/OPERATIONS.md
Normal file
41
docs/OPERATIONS.md
Normal file
|
|
@ -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
|
||||||
|
```
|
||||||
3
docs/open_data_events_template.csv
Normal file
3
docs/open_data_events_template.csv
Normal file
|
|
@ -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
|
||||||
|
42
docs/open_data_sources.json
Normal file
42
docs/open_data_sources.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
306
src/app.js
Normal file
306
src/app.js
Normal file
|
|
@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
86
src/index.html
Normal file
86
src/index.html
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>StaySense NRW MVP</title>
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="background haze-a"></div>
|
||||||
|
<div class="background haze-b"></div>
|
||||||
|
|
||||||
|
<header class="top">
|
||||||
|
<h1>StaySense</h1>
|
||||||
|
<p>Kreis Mettmann Pilot: ruhige Nacht in weniger als 10 Sekunden bewerten.</p>
|
||||||
|
<small id="network-status">Netzwerkstatus wird geprueft ...</small>
|
||||||
|
<small id="data-status">Datenstand: -</small>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="grid">
|
||||||
|
<section class="panel input-panel">
|
||||||
|
<h2>Standort</h2>
|
||||||
|
<div class="field-grid">
|
||||||
|
<label>
|
||||||
|
Latitude
|
||||||
|
<input id="lat" type="number" step="0.000001" placeholder="51.2500" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Longitude
|
||||||
|
<input id="lon" type="number" step="0.000001" placeholder="6.9700" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button-row">
|
||||||
|
<button id="use-location" class="btn secondary">Aktuellen Standort nutzen</button>
|
||||||
|
<button id="load-score" class="btn">Score fuer heute Nacht (22-06)</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="hint">Night Hours im MVP fix: 22:00 bis 06:00.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel result-panel">
|
||||||
|
<h2>Quick Decision</h2>
|
||||||
|
<div class="decision" id="decision-card">
|
||||||
|
<div class="score-wrap">
|
||||||
|
<div class="score" id="score">--</div>
|
||||||
|
<div class="ampel" id="ampel">-</div>
|
||||||
|
</div>
|
||||||
|
<p id="night-window">Bezug: Heute Nacht 22:00-06:00</p>
|
||||||
|
<ul id="reasons" class="reasons">
|
||||||
|
<li>Noch keine Daten geladen.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Community Signal</h3>
|
||||||
|
<div class="signal-grid">
|
||||||
|
<button class="btn signal" data-signal="calm">Ruhige Nacht</button>
|
||||||
|
<button class="btn signal" data-signal="noise">Laerm</button>
|
||||||
|
<button class="btn signal" data-signal="knock">Klopfen</button>
|
||||||
|
<button class="btn signal" data-signal="police">Polizei</button>
|
||||||
|
</div>
|
||||||
|
<small id="signal-status">Noch kein Signal gesendet.</small>
|
||||||
|
<small id="queue-status">Queue: 0 ausstehend</small>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel settings-panel">
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<label class="toggle">
|
||||||
|
<input id="signals-enabled" type="checkbox" checked />
|
||||||
|
Community Signals aktivieren
|
||||||
|
</label>
|
||||||
|
<p class="small">Offline werden Signale gequeued und spaeter gesendet.</p>
|
||||||
|
|
||||||
|
<h3>Rechtliches</h3>
|
||||||
|
<ul class="legal">
|
||||||
|
<li><a href="#" id="show-attribution">Attribution (OSM/ODbL)</a></li>
|
||||||
|
<li><a href="#" id="show-privacy">Datenschutz</a></li>
|
||||||
|
<li><a href="#" id="show-imprint">Impressum</a></li>
|
||||||
|
</ul>
|
||||||
|
<div id="legal-output" class="legal-output"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
232
src/styles.css
Normal file
232
src/styles.css
Normal file
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue