Bläddra i källkod

minukodu-web: uus leht jyri.odamus.com (Suuremõisa välistemperatuur + transport)

- uus FastAPI teenus minukodu-web (port 8096, main_ai_network + prod_net)
- lihtsustatud vaade nutikodu lehest: Andurid = üks välistemperatuuri kaart
  (uus67.valisilm, Open-Meteo / Suuremõisa), Transport identne nutikodu lehele
- nutikodu-web jääb muutmata
ardo 1 vecka sedan
förälder
incheckning
89e2cf6a1b

+ 32 - 0
dev/docker-compose.yml

@@ -131,6 +131,38 @@ services:
       - main_ai_network
       - prod_net
 
+  # Minu kodu veebiliides (jyri.odamus.com): lihtsustatud vaade nutikodu lehest —
+  # ainult Suuremõisa välistemperatuur + identne transpordi vaheleht (FastAPI).
+  # Avalik https://jyri.odamus.com · Tailscale: http://100.87.1.24:8096
+  minukodu-web:
+    build: ./minukodu-web
+    container_name: minukodu-web
+    restart: unless-stopped
+    mem_limit: 256m
+    env_file:
+      - .env
+    environment:
+      - TZ=Europe/Tallinn
+      - CH_URL=${CH_URL:-http://clickhouse:8123}
+      - CH_DB=${CH_DB:-uus67}
+      - CH_USER=${CH_USER:-zigbee}
+      - OTEL_SERVICE_NAME=minukodu-web
+      - OTEL_TRACES_EXPORTER=otlp
+      - OTEL_METRICS_EXPORTER=none
+      - OTEL_LOGS_EXPORTER=none
+      - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
+      - OTEL_EXPORTER_OTLP_PROTOCOL=grpc
+      # ClickStack collector nõuab OTLP-s authorization: <HYPERDX_INGEST_KEY>
+      - OTEL_EXPORTER_OTLP_HEADERS=authorization=${HYPERDX_INGEST_KEY}
+      - OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=ohmu
+    ports:
+      - "100.87.1.24:8096:8096"
+    depends_on:
+      - clickhouse
+    networks:
+      - main_ai_network
+      - prod_net
+
   # ClickStack (ametlik "Docker Compose" arhitektuur): HyperDX app-only + eraldi Mongo.
   # (Varem: all-in-one, mis dubleeris ClickHouse'i + Mongo.) NB: hostil port 8123 = Home Assistant,
   # ClickHouse HTTP = 8125 (vt LOEMIND). UI = 18080.

+ 15 - 0
dev/minukodu-web/Dockerfile

@@ -0,0 +1,15 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt \
+ && pip install --no-cache-dir opentelemetry-distro opentelemetry-exporter-otlp-proto-grpc \
+ && opentelemetry-bootstrap -a install
+
+COPY app.py .
+COPY static ./static
+
+EXPOSE 8096
+
+CMD ["opentelemetry-instrument", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8096"]

+ 74 - 0
dev/minukodu-web/README.md

@@ -0,0 +1,74 @@
+# Minu kodu veebiliides
+
+Lihtsustatud vaade `nutikodu` lehest (https://nutikodu.odamus.com), eraldi
+lehe jaoks. FastAPI backend + üks staatiline leht. Eestikeelne, mobiilisõbralik,
+tume/hele teema.
+
+## Ligipääs
+
+| Keskkond | URL |
+|----------|-----|
+| Avalik | https://jyri.odamus.com |
+| Tailscale | http://100.87.1.24:8096 |
+
+## Funktsioonid
+
+- **Vaheleht "Andurid"** — ainult üks kaart: Suuremõisa **välistemperatuur**
+  (ClickHouse `uus67.valisilm`, Open-Meteo), 60 s värskendus. Graafikuid ei ole.
+- **Vaheleht "Transport"** — identne nutikodu lehele: tänased bussiväljumised
+  Suuremõisa külast (peatus.ee) ja Heltermaa–Rohuküla praamiajad + seisakud/
+  tühistused (praamid.ee) ning Väinamere ilmahoiatuse kontekst (RII).
+  60 s värskendus.
+- PWA-manifest (installitav telefonis), `noindex`.
+
+## API
+
+| Otspunkt | Kirjeldus |
+|----------|-----------|
+| `GET /api/latest` | Suuremõisa välistemperatuur (hetkeväärtus, avalik) |
+| `GET /api/buss?limit=12` | Suuremõisa küla järgmised bussiväljumised (avalik) |
+| `GET /api/praam?days=1` | Heltermaa–Rohuküla väljumised + staatus + ilmahoiatuse kontekst (avalik) |
+| `GET /health` | tervis |
+
+## Keskkonnamuutujad
+
+| Muutuja | Vaikimisi | Kirjeldus |
+|---------|-----------|-----------|
+| `CH_URL` | `http://clickhouse:8123` | ClickHouse HTTP |
+| `CH_DB` | `uus67` | andmebaas |
+| `CH_USER` / `CH_PASS` | `zigbee` / – | ainult SELECT `uus67.*` |
+| `BUS_GRAPHQL` | peatus.ee Digitransit | bussigraafiku GraphQL API |
+| `BUS_STOPS` | `estonia:24703,estonia:27213` | Suuremõisa küla GTFS peatus-ID-d (komaga) |
+| `PRAAM_API` | `https://www.praamid.ee/online/events` | praamigraafiku API |
+| `TRANSPORT_TTL` | `60` | bussi/praami väliste päringute vahemälu (sek) |
+
+## Struktuur
+
+```
+minukodu-web/
+├── app.py                 # FastAPI backend (ClickHouse HTTP, JSONEachRow)
+├── requirements.txt
+├── Dockerfile             # python:3.12-slim, port 8096
+└── static/
+    ├── index.html         # üks leht, eestikeelne
+    ├── style.css          # light/dark, mobiil-first
+    ├── app.js             # välistemperatuuri kaart, perioodilüliti, teema, transport
+    ├── manifest.webmanifest
+    └── icon.svg
+```
+
+## Juurutus
+
+Teenus on `dev/docker-compose.yml`-is (võrgud `main_ai_network` + `prod_net`).
+
+```bash
+docker compose -f /opt/docker-compose/dev/docker-compose.yml up -d --build minukodu-web
+docker logs -f minukodu-web
+```
+
+### Avalik ligipääs
+
+- **DNS:** DigitalOcean A-kirje `jyri.odamus.com` → `188.166.84.16`.
+- **Edge Caddy** (`kanad`, `/opt/edge/Caddyfile`): `jyri.odamus.com { reverse_proxy http://100.91.118.16:80 }`.
+- **Sisemine Caddy** (ohmu `prod_caddy`, `/opt/prod/Caddyfile`): `jyri.odamus.com:80 { reverse_proxy minukodu-web:8096 }`.
+- TLS väljastab edge automaatselt.

+ 358 - 0
dev/minukodu-web/app.py

@@ -0,0 +1,358 @@
+"""Minu kodu veebiliides (FastAPI).
+
+Lihtsustatud vaade nutikodu lehest (https://nutikodu.odamus.com), mõeldud
+eraldiseisva lehe jaoks. Loeb ClickHouse'i `uus67` skeemist ainult
+välistemperatuuri (valisilm, Open-Meteo / Suuremõisa) ja serveerib
+transpordi vahelehe (bussid + praamid) identselt nutikodu lehele.
+
+  GET /                 -> veebirakendus (static/index.html)
+  GET /api/latest       -> Suuremõisa välistemperatuur (hetkeväärtus)
+  GET /api/buss         -> Suuremõisa küla järgmised bussiväljumised (avalik)
+  GET /api/praam        -> Heltermaa–Rohuküla väljumised + ilmahoiatuse kontekst
+  GET /health           -> tervis
+
+ClickHouse'i päringud kasutavad `zigbee` kasutajat (SELECT uus67.*) ja
+`param_*` parameetreid (SQL-süst välistatud). Tulemused loetakse
+`FORMAT JSONEachRow` kujul (üks JSON-objekt rea kohta).
+"""
+
+from __future__ import annotations
+
+import html
+import json
+import os
+import re
+import threading
+import time
+from datetime import datetime, timedelta
+from pathlib import Path
+from zoneinfo import ZoneInfo
+
+import requests
+from fastapi import FastAPI, HTTPException, Query
+from fastapi.responses import FileResponse
+from fastapi.staticfiles import StaticFiles
+
+ROOT = Path(__file__).parent
+STATIC_DIR = ROOT / "static"
+
+TZ = ZoneInfo("Europe/Tallinn")
+
+CH_URL = os.environ.get("CH_URL", "http://clickhouse:8123").rstrip("/")
+CH_USER = os.environ.get("CH_USER", "zigbee")
+CH_PASS = os.environ.get("CH_PASS", "")
+CH_DB = os.environ.get("CH_DB", "uus67")
+
+# --- Transport (bussid + praamid) ---
+BUS_GRAPHQL = os.environ.get(
+    "BUS_GRAPHQL", "https://api.peatus.ee/routing/v1/routers/estonia/index/graphql"
+).rstrip("/")
+BUS_STOPS = [s.strip() for s in os.environ.get(
+    "BUS_STOPS", "estonia:24703,estonia:27213").split(",") if s.strip()]
+PRAAM_API = os.environ.get(
+    "PRAAM_API", "https://www.praamid.ee/online/events").rstrip("/")
+RII_HOIATUS_URL = os.environ.get(
+    "RII_HOIATUS_URL", "https://www.ilmateenistus.ee/ilm/prognoosid/hoiatused/"
+)
+TRANSPORT_TTL = int(os.environ.get("TRANSPORT_TTL", "60"))
+UA = "Mozilla/5.0 (compatible; Minukodu-web/1.0)"
+
+PRAAM_DIRECTIONS = {"HR": "Heltermaa → Rohuküla", "RH": "Rohuküla → Heltermaa"}
+SHIP_NAMES = {"TI": "Tiiu", "LE": "Leiger", "RE": "Regu"}
+PRAAM_STATUS = {"CONFIRMED": "plaanipärane", "CANCELLED": "tühistatud", "SUSPENDED": "seisatud"}
+SEA_AREAS = ["Väinameri", "Hiiu maakond", "Lääne maakond"]
+WIND_TYPES = ["tugev tuul", "väga tugev tuul", "torm", "merehoiatus", "tugev tuul ja lained"]
+
+_cache: dict[str, tuple[float, object]] = {}
+_cache_lock = threading.Lock()
+
+
+def cached(key: str, ttl: int, producer):
+    """Lihtne mälusisene TTL-vahemälu väliste API-de jaoks."""
+    now = time.monotonic()
+    with _cache_lock:
+        hit = _cache.get(key)
+        if hit and now - hit[0] < ttl:
+            return hit[1]
+    value = producer()
+    with _cache_lock:
+        _cache[key] = (time.monotonic(), value)
+    return value
+
+
+app = FastAPI(title="Minu kodu", version="1.0.0")
+
+
+@app.middleware("http")
+async def add_no_cache(request, call_next):
+    """Staatika ja API ilma vahemäluta — muidu jääb brauserisse vana JS/HTML."""
+    response = await call_next(request)
+    path = request.url.path
+    if (
+        path == "/"
+        or path.startswith("/api/")
+        or path.endswith((".js", ".css", ".html", ".webmanifest", ".svg"))
+    ):
+        response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
+        response.headers["Pragma"] = "no-cache"
+    return response
+
+
+def ch_query(query: str, params: dict[str, str] | None = None) -> list[dict]:
+    """Käivita ClickHouse'i päring ja tagasta read JSON-objektidena."""
+    q = {"database": CH_DB, "query": f"{query} FORMAT JSONEachRow"}
+    for key, value in (params or {}).items():
+        q[f"param_{key}"] = value
+    try:
+        resp = requests.post(
+            f"{CH_URL}/",
+            params=q,
+            auth=(CH_USER, CH_PASS),
+            timeout=20,
+        )
+    except requests.RequestException as exc:
+        raise HTTPException(status_code=502, detail=f"ClickHouse'i ühendus: {exc}") from exc
+    if resp.status_code != 200:
+        raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text.strip()[:400]}")
+    text = resp.text.strip()
+    if not text:
+        return []
+    try:
+        return [json.loads(line) for line in text.splitlines() if line.strip()]
+    except json.JSONDecodeError as exc:
+        raise HTTPException(status_code=502, detail=f"ClickHouse'i vastus: {text[:200]}") from exc
+
+
+@app.get("/api/latest")
+async def latest() -> dict:
+    """Suuremõisa välistemperatuur (valisilm, viimane tunniväärtus)."""
+    rows = ch_query(
+        f"SELECT toString(max(hour)) AS ts_str, "
+        f"argMax(temperature, hour) AS temperature, "
+        f"argMax(humidity, hour) AS humidity, "
+        f"argMax(wind, hour) AS wind, "
+        f"argMax(rain, hour) AS rain, "
+        f"toUInt64(toUnixTimestamp(max(hour))) * 1000 AS t "
+        f"FROM {CH_DB}.valisilm FINAL"
+    )
+    outside: dict = {
+        "temperature": None,
+        "humidity": None,
+        "wind": None,
+        "rain": None,
+        "hour": None,
+        "t": None,
+    }
+    if rows:
+        r = rows[0]
+        outside = {
+            "temperature": r.get("temperature"),
+            "humidity": r.get("humidity"),
+            "wind": r.get("wind"),
+            "rain": r.get("rain"),
+            "hour": r.get("ts_str"),
+            "t": int(r["t"]) if r.get("t") is not None else None,
+        }
+    return {"outside": outside}
+
+
+# --- Transport: bussid (peatus.ee / Digitransit GraphQL) ---
+
+
+def fetch_bus_departures() -> dict:
+    """Tänased/tulevased bussiväljumised Suuremõisa küla peatustest."""
+    fields = (
+        "name stoptimesWithoutPatterns(numberOfDepartures: 40) { "
+        "serviceDay scheduledDeparture realtimeDeparture realtime headsign "
+        "trip { route { shortName longName } } }"
+    )
+    aliases = " ".join(
+        f's{i}: stop(id: "{sid}") {{ {fields} }}' for i, sid in enumerate(BUS_STOPS)
+    )
+    try:
+        resp = requests.post(
+            BUS_GRAPHQL,
+            json={"query": "{ " + aliases + " }"},
+            headers={"User-Agent": UA},
+            timeout=18,
+        )
+    except requests.RequestException as exc:
+        raise HTTPException(status_code=502, detail=f"Bussi API ühendus: {exc}") from exc
+    if resp.status_code != 200:
+        raise HTTPException(status_code=502, detail=f"Bussi API: HTTP {resp.status_code}")
+    try:
+        payload = resp.json()
+    except ValueError as exc:
+        raise HTTPException(status_code=502, detail="Bussi API: vigane JSON") from exc
+    if payload.get("errors"):
+        msg = payload["errors"][0].get("message", "viga")
+        raise HTTPException(status_code=502, detail=f"Bussi API: {msg}")
+
+    departures = []
+    for stop in (payload.get("data") or {}).values():
+        if not stop or not stop.get("stoptimesWithoutPatterns"):
+            continue
+        stop_name = stop.get("name") or "Suuremõisa"
+        for st in stop["stoptimesWithoutPatterns"]:
+            service_day = st.get("serviceDay")
+            if service_day is None:
+                continue
+            sched_s = st.get("scheduledDeparture") or 0
+            real_s = st.get("realtimeDeparture")
+            dep_s = real_s if st.get("realtime") and real_s is not None else sched_s
+            try:
+                dep_dt = datetime.fromtimestamp(int(service_day) + int(dep_s), TZ)
+            except (OverflowError, OSError, TypeError, ValueError):
+                continue
+            route = (st.get("trip") or {}).get("route") or {}
+            delay = None
+            if st.get("realtime") and real_s is not None:
+                delay = round((int(real_s) - int(sched_s)) / 60)
+            departures.append({
+                "t": int(dep_dt.timestamp() * 1000),
+                "time": dep_dt.strftime("%H:%M"),
+                "date": dep_dt.strftime("%Y-%m-%d"),
+                "stop": stop_name,
+                "route": route.get("shortName") or "",
+                "headsign": st.get("headsign") or "",
+                "realtime": bool(st.get("realtime")),
+                "delay_min": delay,
+            })
+    departures.sort(key=lambda d: d["t"])
+    now = datetime.now(TZ)
+    return {
+        "stops": BUS_STOPS,
+        "today": now.strftime("%Y-%m-%d"),
+        "departures": departures,
+        "updated": now.strftime("%Y-%m-%d %H:%M"),
+    }
+
+
+@app.get("/api/buss")
+async def buss(limit: int = Query(12, ge=1, le=40)) -> dict:
+    """Järgmised bussiväljumised Suuremõisa külast (avalik)."""
+    data = cached("buss", TRANSPORT_TTL, fetch_bus_departures)
+    return {**data, "departures": data["departures"][:limit]}
+
+
+# --- Transport: praamid (praamid.ee) ---
+
+
+def _praam_events(day: str, direction: str) -> list[dict]:
+    try:
+        resp = requests.get(
+            PRAAM_API,
+            params={"direction": direction, "departure-date": day, "time-shift": "0"},
+            headers={"User-Agent": UA},
+            timeout=18,
+        )
+    except requests.RequestException:
+        return []
+    if resp.status_code != 200:
+        return []
+    try:
+        items = (resp.json() or {}).get("items") or []
+    except ValueError:
+        return []
+    out = []
+    for e in items:
+        try:
+            dep_dt = datetime.strptime((e.get("dtstart") or "")[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=TZ)
+        except ValueError:
+            continue
+        ship = e.get("ship")
+        ship_code = ship.get("code") if isinstance(ship, dict) else ship
+        status = e.get("status") or "?"
+        out.append({
+            "uid": e.get("uid"),
+            "direction": direction,
+            "direction_name": PRAAM_DIRECTIONS.get(direction, direction),
+            "date": dep_dt.strftime("%Y-%m-%d"),
+            "time": dep_dt.strftime("%H:%M"),
+            "t": int(dep_dt.timestamp() * 1000),
+            "ship": SHIP_NAMES.get(ship_code, ship_code or "?"),
+            "ship_code": ship_code,
+            "status": status,
+            "status_et": PRAAM_STATUS.get(status, status),
+            "disrupted": status != "CONFIRMED",
+        })
+    return out
+
+
+def fetch_weather_warnings() -> list[dict]:
+    """RII hoiatusleht -> Väinamere-liiklusega seotud aktiivsed hoiatused (parim pingutus)."""
+    try:
+        resp = requests.get(RII_HOIATUS_URL, headers={"User-Agent": UA}, timeout=18)
+    except requests.RequestException:
+        return []
+    if resp.status_code != 200:
+        return []
+    text = html.unescape(resp.text)
+    text = re.sub(r"<script.*?</script>", " ", text, flags=re.S)
+    text = re.sub(r"<[^>]+>", " ", text)
+    text = re.sub(r"\s+", " ", text)
+    found, seen = [], set()
+    for area in SEA_AREAS:
+        i = 0
+        while True:
+            i = text.find(area, i)
+            if i < 0:
+                break
+            seg = text[max(0, i - 100): i + 700]
+            if any(w in seg.lower() for w in WIND_TYPES):
+                m = re.search(r"Kehtib[^.]{0,200}", seg)
+                valid = m.group(0).strip() if m else ""
+                key = (area, valid or seg[:80])
+                if key not in seen:
+                    seen.add(key)
+                    found.append({"area": area, "valid": valid, "text": seg.strip()[:300]})
+            i += len(area)
+    return found
+
+
+def fetch_praam(days: int) -> dict:
+    now = datetime.now(TZ)
+    day_list = [(now + timedelta(days=n)).strftime("%Y-%m-%d") for n in range(days)]
+    departures = []
+    for day in day_list:
+        for direction in ("HR", "RH"):
+            departures.extend(_praam_events(day, direction))
+    departures.sort(key=lambda d: (d["date"], d["time"]))
+    return {
+        "date": day_list[0],
+        "today": now.strftime("%Y-%m-%d"),
+        "departures": departures,
+        "warnings": fetch_weather_warnings(),
+        "disrupted": sum(1 for d in departures if d["disrupted"]),
+        "updated": now.strftime("%Y-%m-%d %H:%M"),
+    }
+
+
+@app.get("/api/praam")
+async def praam(days: int = Query(1, ge=1, le=3)) -> dict:
+    """Heltermaa–Rohuküla praamiväljumised + ilmahoiatuse kontekst (avalik)."""
+    return cached(f"praam:{days}", TRANSPORT_TTL, lambda: fetch_praam(days))
+
+
+@app.get("/health")
+async def health() -> dict:
+    try:
+        ch_query("SELECT 1")
+        ch_ok = True
+    except HTTPException:
+        ch_ok = False
+    return {"status": "ok", "clickhouse": ch_ok}
+
+
+@app.get("/")
+async def index() -> FileResponse:
+    return FileResponse(STATIC_DIR / "index.html", media_type="text/html")
+
+
+app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
+
+
+if __name__ == "__main__":
+    import uvicorn
+
+    uvicorn.run("app:app", host="0.0.0.0", port=8096, reload=True)

+ 5 - 0
dev/minukodu-web/requirements.txt

@@ -0,0 +1,5 @@
+fastapi
+uvicorn
+requests
+itsdangerous
+python-multipart

+ 227 - 0
dev/minukodu-web/static/app.js

@@ -0,0 +1,227 @@
+"use strict";
+
+const $ = (sel) => document.querySelector(sel);
+
+function pad(n) {
+  return String(n).padStart(2, "0");
+}
+
+function setStatus(ok) {
+  const el = $("#status");
+  if (!el) return;
+  el.classList.toggle("ok", ok === true);
+  el.classList.toggle("err", ok === false);
+}
+
+// --- Välistemperatuur (Suuremõisa) ---
+async function loadLatest() {
+  setStatus(null);
+  try {
+    const res = await fetch("/api/latest");
+    if (!res.ok) throw new Error(await res.text());
+    const data = await res.json();
+    renderCards(data);
+    setStatus(true);
+  } catch (e) {
+    console.error(e);
+    setStatus(false);
+  }
+}
+
+function renderCards(data) {
+  const host = $("#cards");
+  if (!host) return;
+  const out = data.outside || {};
+  const temp = out.temperature == null ? "–" : out.temperature;
+  const updated = out.hour ? `<div class="card-sub">Uuendatud ${out.hour}</div>` : "";
+  host.innerHTML = `<div class="card out">
+      <div class="k">🌤️ Välistemperatuur</div>
+      <div class="v">${temp}<small> °C</small></div>
+      <div class="card-sub">Suuremõisa</div>
+      ${updated}
+    </div>`;
+}
+
+// --- Teema ---
+function setTheme(theme) {
+  document.documentElement.setAttribute("data-theme", theme);
+  localStorage.setItem("minukodu-theme", theme);
+  const toggle = $("#theme-toggle");
+  if (toggle) toggle.textContent = theme === "dark" ? "☀️" : "🌙";
+  const meta = $("#meta-theme");
+  if (meta) meta.setAttribute("content", theme === "dark" ? "#0e1116" : "#1f6feb");
+}
+
+// --- Vahelehed (tabid): Andurid | Transport ---
+const transport = { active: false, days: 1 };
+
+function switchView(view) {
+  const isTransport = view === "transport";
+  document.querySelectorAll("#tabs .tab").forEach((b) => {
+    b.classList.toggle("active", b.dataset.view === view);
+  });
+  const vAnd = document.getElementById("view-andurid");
+  const vTr = document.getElementById("view-transport");
+  if (vAnd) vAnd.hidden = isTransport;
+  if (vTr) vTr.hidden = !isTransport;
+  transport.active = isTransport;
+  if (location.hash !== "#" + view) history.replaceState(null, "", "#" + view);
+  if (isTransport) loadTransport();
+}
+
+function initTabs() {
+  document.querySelectorAll("#tabs .tab").forEach((btn) => {
+    btn.addEventListener("click", () => switchView(btn.dataset.view));
+  });
+  const days = document.getElementById("praam-days");
+  if (days) {
+    days.querySelectorAll("button").forEach((b) => {
+      b.addEventListener("click", () => {
+        days.querySelectorAll("button").forEach((x) => x.classList.remove("active"));
+        b.classList.add("active");
+        transport.days = Number(b.dataset.days) || 1;
+        loadPraam();
+      });
+    });
+  }
+  setInterval(() => {
+    if (transport.active) loadTransport();
+  }, 60000);
+  switchView(location.hash === "#transport" ? "transport" : "andurid");
+}
+
+function loadTransport() {
+  loadBus();
+  loadPraam();
+}
+
+function fmtDayLabel(iso, today) {
+  if (!iso || iso === today) return "";
+  const d = new Date(iso + "T00:00:00");
+  const t = new Date((today || iso) + "T00:00:00");
+  const diff = Math.round((d - t) / 864e5);
+  if (diff === 1) return "homme";
+  return d.toLocaleDateString("et-EE", { weekday: "short", day: "numeric", month: "short" });
+}
+
+function setTransportUpdated(updated) {
+  const el = document.getElementById("transport-updated");
+  if (el && updated) el.textContent = `Uuendatud ${updated}`;
+}
+
+async function loadBus() {
+  const hint = document.getElementById("bus-hint");
+  try {
+    const res = await fetch("/api/buss?limit=12");
+    if (!res.ok) throw new Error("HTTP " + res.status);
+    renderBus(await res.json());
+  } catch (e) {
+    console.error(e);
+    if (hint) { hint.hidden = false; hint.textContent = "Bussiaegade laadimine ebaõnnestus."; }
+  }
+}
+
+function renderBus(data) {
+  const list = document.getElementById("bus-list");
+  const hint = document.getElementById("bus-hint");
+  if (!list) return;
+  const rows = data.departures || [];
+  if (!rows.length) {
+    list.innerHTML = "";
+    if (hint) { hint.hidden = false; hint.textContent = "Täna enam busse ei välju."; }
+    return;
+  }
+  if (hint) hint.hidden = true;
+  list.innerHTML = rows.map((d) => {
+    const day = fmtDayLabel(d.date, data.today);
+    const dayBadge = day ? `<span class="badge muted">${day}</span>` : "";
+    let rt = "";
+    if (d.realtime && d.delay_min != null && d.delay_min !== 0) {
+      rt = d.delay_min > 0
+        ? `<span class="badge warn">+${d.delay_min} min</span>`
+        : `<span class="badge ok">${d.delay_min} min</span>`;
+    }
+    return `<div class="dep">
+      <span class="dep-time">${d.time}</span>
+      <span class="dep-route">${d.route || ""}</span>
+      <span class="dep-dest">→ ${d.headsign || "?"}</span>
+      ${rt}${dayBadge}
+    </div>`;
+  }).join("");
+}
+
+async function loadPraam() {
+  const hint = document.getElementById("praam-hint");
+  try {
+    const res = await fetch(`/api/praam?days=${transport.days}`);
+    if (!res.ok) throw new Error("HTTP " + res.status);
+    renderPraam(await res.json());
+  } catch (e) {
+    console.error(e);
+    if (hint) { hint.hidden = false; hint.textContent = "Praamiaegade laadimine ebaõnnestus."; }
+  }
+}
+
+function renderPraam(data) {
+  const list = document.getElementById("praam-list");
+  const hint = document.getElementById("praam-hint");
+  const warn = document.getElementById("praam-warning");
+  const rows = data.departures || [];
+
+  if (warn) {
+    if (Array.isArray(data.warnings) && data.warnings.length) {
+      warn.hidden = false;
+      warn.innerHTML = "⚠️ <b>Ilmahoiatuse kontekst (Väinameri)</b>" +
+        data.warnings.map((w) => `<div>• ${w.area}: ${w.valid || w.text}</div>`).join("") +
+        `<div class="warn-sub">Praamiliiklus võib häiruda — kontrolli ` +
+        `<a href="https://praamid.ee" target="_blank" rel="noopener">praamid.ee</a>.</div>`;
+    } else {
+      warn.hidden = true;
+    }
+  }
+
+  if (!rows.length) {
+    list.innerHTML = "";
+    if (hint) { hint.hidden = false; hint.textContent = "Väljumisi ei leitud."; }
+    return;
+  }
+  if (hint) hint.hidden = true;
+  setTransportUpdated(data.updated);
+
+  const groups = {};
+  for (const d of rows) (groups[d.direction] = groups[d.direction] || []).push(d);
+  list.innerHTML = ["HR", "RH"].filter((k) => groups[k]).map((k) => {
+    const items = groups[k].map((d) => {
+      const day = fmtDayLabel(d.date, data.today);
+      const dayBadge = day ? `<span class="badge muted">${day}</span>` : "";
+      const st = d.disrupted
+        ? `<span class="badge err">${d.status_et}</span>`
+        : `<span class="badge ok">${d.status_et}</span>`;
+      return `<div class="dep">
+        <span class="dep-time">${d.time}</span>
+        <span class="dep-ship">${d.ship || ""}</span>
+        <span class="dep-dest"></span>
+        ${st}${dayBadge}
+      </div>`;
+    }).join("");
+    return `<div class="dep-group"><div class="dep-group-title">${groups[k][0].direction_name}</div>${items}</div>`;
+  }).join("");
+}
+
+function init() {
+  const saved = localStorage.getItem("minukodu-theme");
+  const prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
+  setTheme(saved || (prefersDark ? "dark" : "light"));
+  const toggle = $("#theme-toggle");
+  if (toggle) {
+    toggle.addEventListener("click", () => {
+      setTheme(document.documentElement.getAttribute("data-theme") === "dark" ? "light" : "dark");
+    });
+  }
+
+  initTabs();
+  loadLatest();
+  setInterval(loadLatest, 60000);
+}
+
+document.addEventListener("DOMContentLoaded", init);

+ 6 - 0
dev/minukodu-web/static/icon.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
+  <rect width="64" height="64" rx="14" fill="#1f6feb"/>
+  <path d="M32 12 13 28h6v20h10V36h6v12h10V28h6L32 12z" fill="#fff"/>
+  <rect x="30" y="20" width="4" height="14" rx="2" fill="#e8590c"/>
+  <circle cx="32" cy="37" r="4" fill="#e8590c"/>
+</svg>

+ 71 - 0
dev/minukodu-web/static/index.html

@@ -0,0 +1,71 @@
+<!DOCTYPE html>
+<html lang="et" data-theme="light">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
+  <meta name="theme-color" content="#1f6feb" id="meta-theme" />
+  <meta name="robots" content="noindex" />
+  <title>Minu kodu</title>
+  <link rel="icon" href="/icon.svg" type="image/svg+xml" />
+  <link rel="manifest" href="/manifest.webmanifest" />
+  <link rel="apple-touch-icon" href="/icon.svg" />
+  <link rel="stylesheet" href="/style.css" />
+</head>
+<body>
+  <header>
+    <div class="brand">
+      <span class="logo" aria-hidden="true">🏠</span>
+      <div>
+        <h1>Minu kodu</h1>
+        <div class="sub" id="subtitle">Suuremõisa</div>
+      </div>
+    </div>
+    <div class="header-actions">
+      <span class="status" id="status" title="Ühenduse olek"></span>
+      <button id="theme-toggle" class="icon-btn" title="Vaheta teema" aria-label="Vaheta teema">🌙</button>
+    </div>
+  </header>
+
+  <main>
+    <nav class="tabs" id="tabs" aria-label="Vahelehed">
+      <button type="button" class="tab active" data-view="andurid">🏠 Andurid</button>
+      <button type="button" class="tab" data-view="transport">🚌 Transport</button>
+    </nav>
+
+    <div id="view-andurid" class="view">
+      <section class="cards single" id="cards"></section>
+
+      <footer class="foot">
+        <span id="range-label"></span>
+      </footer>
+    </div>
+
+    <div id="view-transport" class="view" hidden>
+      <section class="panel">
+        <h2 class="panel-title">🚌 Suuremõisa küla — bussid</h2>
+        <div id="bus-list" class="dep-list"></div>
+        <div id="bus-hint" class="hint" hidden></div>
+      </section>
+
+      <section class="panel">
+        <div class="panel-head">
+          <h2 class="panel-title">🚢 Heltermaa – Rohuküla</h2>
+          <div class="seg" id="praam-days">
+            <button type="button" data-days="1" class="active">Täna</button>
+            <button type="button" data-days="2">Täna + homme</button>
+          </div>
+        </div>
+        <div id="praam-warning" class="warn-banner" hidden></div>
+        <div id="praam-list" class="dep-list"></div>
+        <div id="praam-hint" class="hint" hidden></div>
+      </section>
+
+      <footer class="foot">
+        <span id="transport-updated"></span>
+      </footer>
+    </div>
+  </main>
+
+  <script src="/app.js"></script>
+</body>
+</html>

+ 12 - 0
dev/minukodu-web/static/manifest.webmanifest

@@ -0,0 +1,12 @@
+{
+  "name": "Minu kodu",
+  "short_name": "Minu kodu",
+  "description": "Suuremõisa välistemperatuur ja transport",
+  "start_url": "/",
+  "display": "standalone",
+  "background_color": "#0e1116",
+  "theme_color": "#1f6feb",
+  "icons": [
+    { "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
+  ]
+}

+ 217 - 0
dev/minukodu-web/static/style.css

@@ -0,0 +1,217 @@
+:root {
+  --bg: #f4f6fb;
+  --panel: #ffffff;
+  --text: #1c2430;
+  --muted: #64708a;
+  --border: #dde3ee;
+  --accent: #1f6feb;
+  --accent-soft: #e7f0ff;
+  --temp: #e8590c;
+  --hum: #1f9ed4;
+  --out: #2fb344;
+  --ok: #2fb344;
+  --warn: #e0a400;
+  --err: #d64545;
+  --shadow: 0 8px 24px rgba(20, 40, 80, .10);
+}
+[data-theme="dark"] {
+  --bg: #0e1116;
+  --panel: #171c24;
+  --text: #e8edf5;
+  --muted: #93a0b5;
+  --border: #28303c;
+  --accent: #5b9bff;
+  --accent-soft: #1b2a44;
+  --temp: #ff8a4c;
+  --hum: #56c3ee;
+  --out: #45d06a;
+  --ok: #45d06a;
+  --warn: #f0c000;
+  --err: #ff6b6b;
+  --shadow: 0 8px 24px rgba(0, 0, 0, .45);
+}
+
+* { box-sizing: border-box; }
+html, body { margin: 0; padding: 0; }
+body {
+  background: var(--bg);
+  color: var(--text);
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
+  -webkit-font-smoothing: antialiased;
+  padding-bottom: env(safe-area-inset-bottom);
+  overflow-x: hidden;
+}
+
+header {
+  position: sticky; top: 0; z-index: 20;
+  display: flex; align-items: center; justify-content: space-between;
+  gap: 12px; padding: 14px 16px calc(14px + env(safe-area-inset-top)) 16px;
+  padding-top: calc(14px + env(safe-area-inset-top));
+  background: color-mix(in srgb, var(--panel) 88%, transparent);
+  backdrop-filter: saturate(180%) blur(12px);
+  border-bottom: 1px solid var(--border);
+}
+.brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
+.logo { font-size: 26px; line-height: 1; }
+.brand h1 { font-size: 18px; margin: 0; letter-spacing: -.2px; }
+.brand .sub { font-size: 12px; color: var(--muted); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.header-actions { display: flex; align-items: center; gap: 8px; }
+.status { width: 10px; height: 10px; border-radius: 50%; background: var(--muted); box-shadow: 0 0 0 4px color-mix(in srgb, var(--muted) 20%, transparent); }
+.status.ok { background: var(--ok); box-shadow: 0 0 0 4px color-mix(in srgb, var(--ok) 20%, transparent); }
+.status.err { background: var(--err); box-shadow: 0 0 0 4px color-mix(in srgb, var(--err) 20%, transparent); }
+.icon-btn {
+  border: 1px solid var(--border); background: var(--panel); color: var(--text);
+  width: 40px; height: 40px; border-radius: 12px; font-size: 18px; cursor: pointer;
+  display: inline-flex; align-items: center; justify-content: center;
+  padding: 0; line-height: 1; text-decoration: none;
+}
+.icon-btn:active { transform: scale(.96); }
+
+main { padding: 14px; max-width: 1100px; margin: 0 auto; display: grid; gap: 14px; }
+main > * { min-width: 0; }
+#protected:not([hidden]) { display: grid; gap: 14px; }
+
+.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; }
+.card {
+  background: var(--panel); border: 1px solid var(--border); border-radius: 16px;
+  padding: 14px; box-shadow: var(--shadow);
+}
+.card .k { font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 6px; }
+.card .v { font-size: 26px; font-weight: 700; margin-top: 6px; letter-spacing: -.5px; }
+.card .v small { font-size: 14px; font-weight: 500; color: var(--muted); }
+.card .card-sub { font-size: 13px; color: var(--muted); margin-top: 2px; }
+.cards.single { grid-template-columns: minmax(0, 260px); justify-content: center; }
+.card.temp .v { color: var(--temp); }
+.card.hum .v { color: var(--hum); }
+.card.out .v { color: var(--out); }
+.card.wide { grid-column: 1 / -1; }
+
+.panel {
+  background: var(--panel); border: 1px solid var(--border); border-radius: 16px;
+  box-shadow: var(--shadow); padding: 12px; overflow: hidden;
+}
+.controls { display: grid; gap: 10px; margin-bottom: 6px; }
+.presets { display: flex; flex-wrap: wrap; gap: 8px; }
+.presets button, .seg button {
+  border: 1px solid var(--border); background: var(--bg); color: var(--text);
+  padding: 8px 14px; border-radius: 999px; font-size: 13px; cursor: pointer; font-weight: 600;
+}
+.presets button.active, .seg button.active { background: var(--accent); border-color: var(--accent); color: #fff; }
+.custom-range { display: flex; flex-wrap: wrap; align-items: end; gap: 8px; }
+.custom-range label { display: grid; gap: 4px; font-size: 11px; color: var(--muted); flex: 1 1 140px; min-width: 0; }
+.custom-range input {
+  border: 1px solid var(--border); background: var(--bg); color: var(--text);
+  border-radius: 10px; padding: 8px 10px; font-size: 13px; width: 100%; min-width: 0; max-width: 100%;
+}
+.btn {
+  border: none; background: var(--accent); color: #fff; border-radius: 10px;
+  padding: 9px 16px; font-size: 13px; font-weight: 600; cursor: pointer;
+}
+.btn:active { filter: brightness(.94); }
+.toggles { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; }
+.chk { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; cursor: pointer; }
+.chk input { accent-color: var(--accent); width: 16px; height: 16px; }
+.seg { display: inline-flex; gap: 0; margin-left: auto; }
+.seg button { border-radius: 0; padding: 8px 14px; }
+.seg button:first-child { border-radius: 999px 0 0 999px; }
+.seg button:last-child { border-radius: 0 999px 999px 0; border-left: none; }
+
+#chart { width: 100%; height: 56vh; min-height: 320px; }
+.hint { color: var(--muted); font-size: 13px; text-align: center; padding: 20px; }
+.foot { color: var(--muted); font-size: 12px; text-align: center; padding: 4px 0 20px; }
+
+@media (max-width: 520px) {
+  .brand h1 { font-size: 16px; }
+  #chart { height: 50vh; min-height: 280px; }
+  .seg { margin-left: 0; }
+  .header-actions { display: grid; grid-template-columns: 10px 40px 40px; gap: 8px; align-items: center; justify-content: end; }
+  .header-actions .status { grid-area: 1 / 1; }
+  #auth-link { grid-area: 1 / 2; }
+  #theme-toggle { grid-area: 1 / 3; }
+  .away-pill { grid-area: 2 / 2 / 3 / 4; justify-content: center; font-size: 12px; padding: 0 6px; }
+}
+
+.away-bar { margin: 0; }
+.away-toggle {
+  width: 100%; border: 1px solid var(--border); background: var(--panel);
+  border-radius: 16px; padding: 16px 18px; display: flex; align-items: center; gap: 14px;
+  cursor: pointer; color: var(--text); text-align: left; box-shadow: var(--shadow);
+  transition: border-color .15s, background .15s;
+}
+.away-toggle:active { transform: scale(.99); }
+.away-toggle .away-ico { font-size: 30px; line-height: 1; }
+.away-toggle .away-txt { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
+.away-toggle .away-txt b { font-size: 18px; }
+.away-toggle .away-txt small { font-size: 12px; color: var(--muted); }
+.away-toggle.away-on { border-color: var(--err); background: color-mix(in srgb, var(--err) 14%, var(--panel)); }
+.away-toggle.away-on .away-txt b { color: var(--err); }
+
+.away-pill {
+  display: inline-flex; align-items: center; gap: 6px;
+  border: 1px solid var(--border); background: var(--panel); color: var(--text);
+  height: 40px; border-radius: 12px; padding: 0 14px; font-size: 13px; font-weight: 600;
+  cursor: pointer; white-space: nowrap; line-height: 1;
+}
+.away-pill .away-ico { font-size: 15px; line-height: 1; }
+.away-pill[hidden] { display: none; }
+.away-pill:active { transform: scale(.96); }
+.away-pill.away-on {
+  border-color: var(--err); background: color-mix(in srgb, var(--err) 16%, var(--panel)); color: var(--err);
+}
+
+/* --- Vahelehed (tabid) --- */
+.tabs {
+  display: flex; gap: 6px; background: var(--panel); border: 1px solid var(--border);
+  border-radius: 14px; padding: 5px; box-shadow: var(--shadow);
+}
+.tab {
+  flex: 1 1 0; border: 0; background: transparent; color: var(--muted);
+  font-size: 14px; font-weight: 600; padding: 10px 12px; border-radius: 10px; cursor: pointer;
+}
+.tab.active { background: var(--accent); color: #fff; }
+.tab:active { transform: scale(.98); }
+
+.view { display: grid; gap: 14px; }
+.view[hidden] { display: none; }
+
+.panel-head {
+  display: flex; align-items: center; justify-content: space-between;
+  gap: 10px; flex-wrap: wrap; margin-bottom: 8px;
+}
+.panel-title { font-size: 15px; margin: 0 0 8px; }
+.panel-head .panel-title { margin: 0; }
+
+.dep-list { display: grid; gap: 6px; }
+.dep-group { display: grid; gap: 6px; margin-bottom: 8px; }
+.dep-group-title {
+  font-size: 12px; color: var(--muted); font-weight: 700;
+  text-transform: uppercase; letter-spacing: .4px; margin-top: 4px;
+}
+.dep {
+  display: flex; align-items: center; gap: 10px; padding: 10px 12px;
+  border: 1px solid var(--border); border-radius: 12px; background: var(--bg);
+}
+.dep-time { font-variant-numeric: tabular-nums; font-weight: 700; font-size: 16px; min-width: 52px; }
+.dep-route { font-weight: 600; color: var(--accent); min-width: 44px; }
+.dep-ship { font-weight: 600; min-width: 56px; }
+.dep-dest { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.badge {
+  font-size: 11px; font-weight: 700; padding: 3px 8px; border-radius: 999px;
+  white-space: nowrap; border: 1px solid transparent;
+}
+.badge.ok { color: var(--ok); background: color-mix(in srgb, var(--ok) 16%, transparent); }
+.badge.err { color: var(--err); background: color-mix(in srgb, var(--err) 16%, transparent); }
+.badge.warn { color: var(--warn); background: color-mix(in srgb, var(--warn) 18%, transparent); }
+.badge.muted { color: var(--muted); background: color-mix(in srgb, var(--muted) 14%, transparent); }
+
+.warn-banner {
+  border: 1px solid var(--warn); background: color-mix(in srgb, var(--warn) 14%, var(--panel));
+  border-radius: 12px; padding: 10px 12px; font-size: 13px; line-height: 1.55; margin-bottom: 8px;
+}
+.warn-banner a { color: var(--accent); }
+.warn-sub { margin-top: 6px; color: var(--muted); font-size: 12px; }
+
+@media (max-width: 520px) {
+  .dep-time { min-width: 44px; font-size: 15px; }
+  .dep-route { min-width: 0; }
+}