소스 검색

varundus 2026-10-09 07:00

ardo 2 주 전
부모
커밋
40149626af
5개의 변경된 파일과 544개의 추가작업 그리고 48개의 파일을 삭제
  1. 9 0
      dev/nutikodu-web/README.md
  2. 217 0
      dev/nutikodu-web/app.py
  3. 186 6
      dev/nutikodu-web/static/app.js
  4. 74 42
      dev/nutikodu-web/static/index.html
  5. 58 0
      dev/nutikodu-web/static/style.css

+ 9 - 0
dev/nutikodu-web/README.md

@@ -22,6 +22,9 @@ tume/hele teema.
 - **Välistemperatuur** (eraldi koguja, Open-Meteo) kuvatakse samal graafikul
   rohelise joonena; niiskus/tuul/sademed salvestatakse andmebaasi.
 - Viimaste väärtuste kaardid + lahtiste häirete ülevaade (60 s värskendus).
+- **Vaheleht "Transport"** — 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, parim pingutus). 60 s värskendus.
 - PWA-manifest (installitav telefonis), `noindex`.
 
 ## API
@@ -37,6 +40,8 @@ tume/hele teema.
 | `GET /api/energia?from=&to=&device=&agg=hour\|day` | 🔒 pistikute energiatarve |
 | `GET /api/uus67_latest` | 🔒 viimane energia + liikumine |
 | `GET /api/latest` | viimased väärtused (lahtised häired ainult sisselogitult) |
+| `GET /api/buss?limit=12` | Suuremõisa küla järgmised bussiväljumised (peatus.ee, avalik) |
+| `GET /api/praam?days=1` | Heltermaa–Rohuküla väljumised + staatus + ilmahoiatuse kontekst (praamid.ee, avalik) |
 | `GET /api/me` | kas päring on autenditud (`{"authed":bool}`) |
 | `GET /health` | tervis |
 
@@ -80,6 +85,10 @@ curl "http://100.87.1.24:8095/health"
 | `SESSION_MAX_AGE` | `604800` | sessiooni kehtivus sekundites (7 p) |
 | `LOGIN_MAX_ATTEMPTS` | `5` | ebaõnnestunud logimiskatsed enne blokeeringut |
 | `LOGIN_WINDOW_SECONDS` | `300` | logimise blokeerimise aken (sek) |
+| `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 (HR=Heltermaa→Rohuküla) |
+| `TRANSPORT_TTL` | `60` | bussi/praami väliste päringute vahemälu (sek) |
 
 ## Struktuur
 

+ 217 - 0
dev/nutikodu-web/app.py

@@ -18,9 +18,11 @@ Valikuline kaitse: WEB_ACCESS_TOKEN keskkonnamuutuja (tühi = kaitseta).
 from __future__ import annotations
 
 import hmac
+import html
 import secrets
 import json
 import os
+import re
 import threading
 import time
 from collections import defaultdict
@@ -57,6 +59,43 @@ LOGIN_WINDOW_SECONDS = int(os.environ.get("LOGIN_WINDOW_SECONDS", "300"))
 DEFAULT_RANGE_DAYS = 7
 MAX_RANGE_DAYS = 3660
 
+# --- 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; Nutikodu-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="Nutikodu", version="0.2.0")
 app.add_middleware(
     SessionMiddleware,
@@ -555,6 +594,184 @@ async def latest(request: Request) -> dict:
 
 
 
+# --- 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))
+
+
 LOGIN_HTML = """<!doctype html>
 <html lang="et"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
 <title>Nutikodu — login</title>

+ 186 - 6
dev/nutikodu-web/static/app.js

@@ -365,8 +365,6 @@ function init() {
     load();
   });
 
-  const awayBtn = document.getElementById("away-toggle");
-  if (awayBtn) awayBtn.addEventListener("click", toggleAway);
   window.addEventListener("resize", () => chart && chart.resize());
   window.addEventListener("orientationchange", () => setTimeout(() => chart && chart.resize(), 250));
   window.addEventListener("load", () => chart && chart.resize());
@@ -375,6 +373,7 @@ function init() {
   loadLatest();
   setInterval(loadLatest, 60000);
   setInterval(loadProtected, 60000);
+  initTabs();
 }
 
 
@@ -510,18 +509,37 @@ function renderUus67Cards(data) {
 
 
 // --- Autentimine ---
+function ensureAwayButton() {
+  if (document.getElementById("away-toggle")) return;
+  const actions = document.querySelector(".header-actions");
+  const authLink = document.getElementById("auth-link");
+  if (!actions || !authLink) return;
+  const btn = document.createElement("button");
+  btn.type = "button";
+  btn.id = "away-toggle";
+  btn.className = "away-pill";
+  btn.setAttribute("aria-label", "Kodust eemal lüliti");
+  btn.innerHTML = '<span class="away-ico">\ud83c\udfe0</span><span id="away-title">Kodus</span>';
+  btn.addEventListener("click", toggleAway);
+  actions.insertBefore(btn, authLink);
+}
+
 function applyAuth(authed) {
   state.authed = authed;
-  const awayBtn = document.getElementById("away-toggle");
-  if (awayBtn) awayBtn.hidden = !authed;
+  if (authed) {
+    ensureAwayButton();
+    loadAway();
+  } else {
+    const awayBtn = document.getElementById("away-toggle");
+    if (awayBtn) awayBtn.remove();
+  }
   const prot = document.getElementById("protected");
   if (prot) prot.hidden = !authed;
-  if (authed) loadAway();
   const link = document.getElementById("auth-link");
   if (link) {
     link.href = authed ? "/logout" : "/login";
     link.title = authed ? "Logi välja" : "Sisselogimine";
-    link.textContent = authed ? "🔓" : "🔒";
+    link.textContent = authed ? "\ud83d\udd13" : "\ud83d\udd12";
   }
 }
 
@@ -594,4 +612,166 @@ async function toggleAway() {
   }
 }
 
+// --- 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();
+  } else {
+    if (chart) chart.resize();
+    if (chartEnergy) chartEnergy.resize();
+    if (chartMotion) chartMotion.resize();
+  }
+}
+
+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("");
+}
+
 document.addEventListener("DOMContentLoaded", init);

+ 74 - 42
dev/nutikodu-web/static/index.html

@@ -22,62 +22,94 @@
     </div>
     <div class="header-actions">
       <span class="status" id="status" title="Ühenduse olek"></span>
-      <button type="button" id="away-toggle" class="away-pill" hidden aria-label="Kodust eemal lüliti"><span class="away-ico">🏠</span><span id="away-title">Kodus</span></button>
       <a id="auth-link" class="icon-btn" href="/login" title="Sisselogimine" style="text-decoration:none">🔒</a>
       <button id="theme-toggle" class="icon-btn" title="Vaheta teema" aria-label="Vaheta teema">🌙</button>
     </div>
   </header>
 
   <main>
-    <section class="cards" id="cards"></section>
+    <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>
 
-    <section class="panel">
-      <div class="controls">
-        <div class="presets" id="presets">
-          <button type="button" data-days="1">24 h</button>
-          <button type="button" data-days="7" class="active">7 p</button>
-          <button type="button" data-days="30">30 p</button>
-          <button type="button" data-days="90">90 p</button>
-          <button type="button" data-days="365">1 a</button>
-          <button type="button" data-days="all">Kõik</button>
-        </div>
-        <div class="custom-range">
-          <label>Algus<input type="datetime-local" id="from" /></label>
-          <label>Lõpp<input type="datetime-local" id="to" /></label>
-          <button type="button" id="apply" class="btn">Kuva</button>
-        </div>
-        <div class="toggles">
-          <label class="chk"><input type="checkbox" id="show-temp" checked /> <span>Temperatuur</span></label>
-          <label class="chk"><input type="checkbox" id="show-hum" checked /> <span>Niiskus</span></label>
-          <label class="chk"><input type="checkbox" id="show-out" checked /> <span>Väli</span></label>
-          <div class="seg" id="agg">
-            <button type="button" data-agg="hour" class="active">Tund</button>
-            <button type="button" data-agg="day">Päev</button>
+    <div id="view-andurid" class="view">
+      <section class="cards" id="cards"></section>
+
+      <section class="panel">
+        <div class="controls">
+          <div class="presets" id="presets">
+            <button type="button" data-days="1">24 h</button>
+            <button type="button" data-days="7" class="active">7 p</button>
+            <button type="button" data-days="30">30 p</button>
+            <button type="button" data-days="90">90 p</button>
+            <button type="button" data-days="365">1 a</button>
+            <button type="button" data-days="all">Kõik</button>
+          </div>
+          <div class="custom-range">
+            <label>Algus<input type="datetime-local" id="from" /></label>
+            <label>Lõpp<input type="datetime-local" id="to" /></label>
+            <button type="button" id="apply" class="btn">Kuva</button>
+          </div>
+          <div class="toggles">
+            <label class="chk"><input type="checkbox" id="show-temp" checked /> <span>Temperatuur</span></label>
+            <label class="chk"><input type="checkbox" id="show-hum" checked /> <span>Niiskus</span></label>
+            <label class="chk"><input type="checkbox" id="show-out" checked /> <span>Väli</span></label>
+            <div class="seg" id="agg">
+              <button type="button" data-agg="hour" class="active">Tund</button>
+              <button type="button" data-agg="day">Päev</button>
+            </div>
           </div>
         </div>
+        <div id="chart" aria-label="Temperatuuri ja niiskuse ajatelg"></div>
+        <div id="hint" class="hint" hidden></div>
+      </section>
+
+      <div id="protected" hidden>
+        <section class="cards" id="cards-uus67"></section>
+
+        <section class="panel">
+          <h2 class="panel-title">⚡ Energia (pistikud)</h2>
+          <div id="chart-energy" aria-label="Energia ajatelg" style="height:300px"></div>
+          <div id="hint-energy" class="hint" hidden></div>
+        </section>
+
+        <section class="panel">
+          <h2 class="panel-title">🚶 Liikumine / kohalolu</h2>
+          <div id="chart-motion" aria-label="Liikumise ajatelg" style="height:220px"></div>
+          <div id="hint-motion" class="hint" hidden></div>
+        </section>
       </div>
-      <div id="chart" aria-label="Temperatuuri ja niiskuse ajatelg"></div>
-      <div id="hint" class="hint" hidden></div>
-    </section>
 
-    <div id="protected" hidden>
-    <section class="cards" id="cards-uus67"></section>
+      <footer class="foot">
+        <span id="range-label"></span>
+      </footer>
+    </div>
 
-    <section class="panel">
-      <h2 class="panel-title">⚡ Energia (pistikud)</h2>
-      <div id="chart-energy" aria-label="Energia ajatelg" style="height:300px"></div>
-      <div id="hint-energy" class="hint" hidden></div>
-    </section>
+    <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">
-      <h2 class="panel-title">🚶 Liikumine / kohalolu</h2>
-      <div id="chart-motion" aria-label="Liikumise ajatelg" style="height:220px"></div>
-      <div id="hint-motion" 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="range-label"></span>
-    </footer>
+      <footer class="foot">
+        <span id="transport-updated"></span>
+      </footer>
+    </div>
   </main>
 
   <script src="/vendor/echarts.min.js"></script>

+ 58 - 0
dev/nutikodu-web/static/style.css

@@ -151,7 +151,65 @@ main > * { min-width: 0; }
   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; }
+}