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
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