diff --git a/gitignore b/.gitignore similarity index 100% rename from gitignore rename to .gitignore diff --git a/README.md b/README.md index 477026a..ec9f51f 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,33 @@ export PBT_SECRET_KEY="ein-langer-zufaelliger-string" python app.py # Testlauf auf Port 5000 ``` -Beim ersten Start legt die App die Tabellen (`users`, `fahrten`) automatisch per +Beim ersten Start legt die App die Tabellen (`users`, `trips`, `stops`) automatisch per `db.create_all()` an – dafür braucht der User `pbt_app` `CREATE`-Rechte in der DB (sollte durch `GRANT ALL PRIVILEGES ON DATABASE pbt TO pbt_app;` bereits gegeben sein). +## Haltestellen-Lookup + +Die Felder „Von" und „Nach" haben eine Autovervollständigung aus allen +österreichischen Haltestellen. Die Daten stammen aus OpenStreetMap +(© OpenStreetMap-Mitwirkende, ODbL) und liegen als Snapshot im Repo +(`data/stops_at.csv.gz`, ~37 000 Haltestellen). + +Nach dem Anlegen der Tabellen den Snapshot in die DB laden: + +```bash +flask import-stops # FLASK_APP=app.py bzw. im Projektverzeichnis +``` + +Der Import ist idempotent (Upsert) und braucht kein Internet. Beim Deploy per +systemd einmalig ausführen, danach nur wenn der Snapshot aktualisiert wurde. + +Snapshot neu von OpenStreetMap holen (dauert 1–2 min, braucht Netzugang): + +```bash +python scripts/fetch_stops.py # überschreibt data/stops_at.csv.gz +flask import-stops +``` + ## Produktivbetrieb mit systemd + Gunicorn 1. Projekt nach `/opt/pbt` kopieren, virtuelle Umgebung dort anlegen (s. oben). diff --git a/__pycache__/app.cpython-312.pyc b/__pycache__/app.cpython-312.pyc deleted file mode 100644 index 140bd9d..0000000 Binary files a/__pycache__/app.cpython-312.pyc and /dev/null differ diff --git a/__pycache__/config.cpython-312.pyc b/__pycache__/config.cpython-312.pyc deleted file mode 100644 index 7d02391..0000000 Binary files a/__pycache__/config.cpython-312.pyc and /dev/null differ diff --git a/__pycache__/models.cpython-312.pyc b/__pycache__/models.cpython-312.pyc deleted file mode 100644 index c0cc737..0000000 Binary files a/__pycache__/models.cpython-312.pyc and /dev/null differ diff --git a/app.py b/app.py index 2b8079a..7a47b27 100644 --- a/app.py +++ b/app.py @@ -1,6 +1,6 @@ from datetime import date -from flask import Flask, render_template, redirect, url_for, request, flash +from flask import Flask, render_template, redirect, url_for, request, flash, jsonify from flask_login import ( LoginManager, login_user, @@ -10,7 +10,8 @@ from flask_login import ( ) from config import Config -from models import db, User, Trip, VERKEHRSMITTEL_OPTIONEN +from models import db, User, Trip, Stop, VERKEHRSMITTEL_OPTIONEN +from stops_import import normalize_name, register_cli login_manager = LoginManager() login_manager.login_view = "login" @@ -27,6 +28,7 @@ def create_app(): db.create_all() register_routes(app) + register_cli(app) return app @@ -97,6 +99,70 @@ def register_routes(app): ) return render_template("dashboard.html", fahrten=fahrten) + @app.route("/api/stops") + @login_required + def api_stops(): + """Autocomplete for the origin/destination fields.""" + query = request.args.get("q", "").strip() + if len(query) < 2: + return jsonify([]) + + needle = normalize_name(query).replace("\\", "\\\\") + needle = needle.replace("%", r"\%").replace("_", r"\_") + order = (db.func.length(Stop.name), Stop.name) + + results = ( + Stop.query.filter(Stop.name_normalized.like(needle + "%", escape="\\")) + .order_by(*order) + .limit(8) + .all() + ) + if len(results) < 8: + seen = {stop.id for stop in results} + for stop in ( + Stop.query.filter( + Stop.name_normalized.like("%" + needle + "%", escape="\\") + ) + .order_by(*order) + .limit(20) + .all() + ): + if stop.id not in seen: + results.append(stop) + if len(results) >= 8: + break + + return jsonify( + [ + { + "id": stop.id, + "name": stop.name, + "type": stop.stop_type, + "municipality": stop.municipality, + "lat": stop.latitude, + "lon": stop.longitude, + } + for stop in results + ] + ) + + def parse_trip_date(raw_value): + raw_value = (raw_value or "").strip() + try: + return date.fromisoformat(raw_value) if raw_value else date.today() + except ValueError: + return date.today() + + def resolve_stop_id(raw_id): + """Return a valid Stop.id for a submitted value, or None (free text).""" + if not raw_id: + return None + try: + stop = db.session.get(Stop, int(raw_id)) + except (TypeError, ValueError): + return None + return stop.id if stop is not None else None + @app.route("/fahrt/neu", methods=["GET", "POST"]) @login_required def neue_fahrt(): @@ -106,8 +172,12 @@ def register_routes(app): transport_mode=request.form["transport_mode"], line=request.form.get("line", "").strip(), origin=request.form["origin"].strip(), + origin_stop_id=resolve_stop_id(request.form.get("origin_stop_id")), destination=request.form["destination"].strip(), - trip_date=request.form.get("trip_date") or date.today(), + destination_stop_id=resolve_stop_id( + request.form.get("destination_stop_id") + ), + trip_date=parse_trip_date(request.form.get("trip_date")), rating=int(request.form["rating"]), comment=request.form.get("comment", "").strip(), ) diff --git a/data/stops_at.csv.gz b/data/stops_at.csv.gz new file mode 100644 index 0000000..52578f0 Binary files /dev/null and b/data/stops_at.csv.gz differ diff --git a/models.py b/models.py index 4e42dab..cffabf6 100644 --- a/models.py +++ b/models.py @@ -36,6 +36,31 @@ VERKEHRSMITTEL_OPTIONEN = [ ] +class Stop(db.Model): + """A boardable public-transport stop ("Haltestelle") in Austria. + + Sourced from OpenStreetMap; see scripts/fetch_stops.py and stops_import.py. + """ + + __tablename__ = "stops" + + id = db.Column(db.Integer, primary_key=True) + osm_type = db.Column(db.String(8), nullable=False) + osm_id = db.Column(db.BigInteger, nullable=False) + + name = db.Column(db.String(200), nullable=False) + # lowercased, accent-folded copy of name for diacritic-insensitive search + name_normalized = db.Column(db.String(200), nullable=False, index=True) + stop_type = db.Column(db.String(20), nullable=False, default="other") + municipality = db.Column(db.String(120)) + latitude = db.Column(db.Float, nullable=False) + longitude = db.Column(db.Float, nullable=False) + + __table_args__ = ( + db.UniqueConstraint("osm_type", "osm_id", name="uq_stops_osm"), + ) + + class Trip(db.Model): __tablename__ = "trips" @@ -44,10 +69,16 @@ class Trip(db.Model): transport_mode = db.Column(db.String(50), nullable=False) line = db.Column(db.String(50)) + # free-text label as entered; *_stop_id links to a known stop when matched origin = db.Column(db.String(120), nullable=False) + origin_stop_id = db.Column(db.Integer, db.ForeignKey("stops.id")) destination = db.Column(db.String(120), nullable=False) + destination_stop_id = db.Column(db.Integer, db.ForeignKey("stops.id")) trip_date = db.Column(db.Date, nullable=False, default=date.today) rating = db.Column(db.Integer, nullable=False) # 1-5 comment = db.Column(db.Text) created_at = db.Column(db.DateTime, server_default=db.func.now()) + + origin_stop = db.relationship("Stop", foreign_keys=[origin_stop_id]) + destination_stop = db.relationship("Stop", foreign_keys=[destination_stop_id]) diff --git a/scripts/fetch_stops.py b/scripts/fetch_stops.py new file mode 100644 index 0000000..ce3b921 --- /dev/null +++ b/scripts/fetch_stops.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +"""Fetch all Austrian public-transport stops from OpenStreetMap (Overpass API) +and write a deduplicated snapshot to data/stops_at.csv.gz. + +Data © OpenStreetMap contributors, licensed under the ODbL. + +Run this only when you want to refresh the snapshot; the import into the +database reads the checked-in CSV and needs no network access. + + python scripts/fetch_stops.py +""" +from __future__ import annotations + +import csv +import gzip +import json +import sys +import unicodedata +import urllib.parse +import urllib.request +from pathlib import Path + +OUT_PATH = Path(__file__).resolve().parent.parent / "data" / "stops_at.csv.gz" + +OVERPASS_ENDPOINTS = [ + "https://overpass-api.de/api/interpreter", + "https://overpass.kumi.systems/api/interpreter", + "https://overpass.private.coffee/api/interpreter", +] + +# Named nodes in Austria that represent a boardable stop ("Haltestelle"). +OVERPASS_QUERY = """ +[out:json][timeout:600]; +area["ISO3166-1"="AT"][admin_level=2]->.at; +( + node(area.at)["highway"="bus_stop"]["name"]; + node(area.at)["railway"="tram_stop"]["name"]; + node(area.at)["railway"="station"]["name"]; + node(area.at)["railway"="halt"]["name"]; + node(area.at)["public_transport"="station"]["name"]; + node(area.at)["amenity"="bus_station"]["name"]; +); +out body; +""" + +CSV_FIELDS = [ + "osm_type", + "osm_id", + "name", + "stop_type", + "municipality", + "latitude", + "longitude", +] + + +def normalize_name(value: str) -> str: + """Lowercase, fold accents and ß so search is diacritic-insensitive. + + The same function lives in stops_import.py; keep them in sync. + """ + value = value.strip().lower().replace("ß", "ss") + decomposed = unicodedata.normalize("NFKD", value) + return "".join(ch for ch in decomposed if not unicodedata.combining(ch)) + + +def classify(tags: dict) -> str: + if tags.get("station") == "subway" or tags.get("subway") == "yes": + return "subway" + if tags.get("railway") in {"station", "halt"}: + return "train" + if tags.get("railway") == "tram_stop" or tags.get("tram") == "yes": + return "tram" + if tags.get("amenity") == "bus_station": + return "bus_station" + if tags.get("highway") == "bus_stop" or tags.get("bus") == "yes": + return "bus" + if tags.get("public_transport") == "station": + return "other" + return "other" + + +def municipality_of(tags: dict) -> str: + for key in ("addr:city", "is_in:municipality", "is_in:city", "is_in"): + value = tags.get(key) + if value: + return value.split(",")[0].strip() + return "" + + +def fetch_raw() -> dict: + body = urllib.parse.urlencode({"data": OVERPASS_QUERY}).encode() + last_error: Exception | None = None + for endpoint in OVERPASS_ENDPOINTS: + try: + print(f"Querying {endpoint} ...", file=sys.stderr) + req = urllib.request.Request( + endpoint, data=body, headers={"User-Agent": "pbt-stop-import/1.0"} + ) + with urllib.request.urlopen(req, timeout=600) as resp: + return json.load(resp) + except Exception as exc: # noqa: BLE001 - try the next mirror + last_error = exc + print(f" failed: {exc}", file=sys.stderr) + raise SystemExit(f"All Overpass endpoints failed; last error: {last_error}") + + +TYPE_RANK = {"subway": 5, "train": 4, "bus_station": 3, "tram": 2, "bus": 1, "other": 0} + +# Nodes with the same name closer than this are treated as one stop (OSM keeps a +# separate node per direction for most bus stops, ~20-60 m apart). +MERGE_METERS = 250.0 + + +def _dist_m(a: dict, b: dict) -> float: + import math + + lat1, lon1, lat2, lon2 = map( + math.radians, + (a["latitude"], a["longitude"], b["latitude"], b["longitude"]), + ) + dlat, dlon = lat2 - lat1, lon2 - lon1 + h = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2 + return 2 * 6_371_000 * math.asin(min(1.0, math.sqrt(h))) + + +def build_rows(payload: dict) -> list[dict]: + # Collect candidate nodes grouped by normalized name. + by_name: dict[str, list[dict]] = {} + for element in payload.get("elements", []): + if element.get("type") != "node": + continue + tags = element.get("tags", {}) + name = (tags.get("name") or "").strip() + lat = element.get("lat") + lon = element.get("lon") + if not name or lat is None or lon is None: + continue + node = { + "osm_type": "node", + "osm_id": int(element["id"]), + "name": name, + "stop_type": classify(tags), + "municipality": municipality_of(tags), + "latitude": round(float(lat), 6), + "longitude": round(float(lon), 6), + } + by_name.setdefault(normalize_name(name), []).append(node) + + rows: list[dict] = [] + for nodes in by_name.values(): + # Greedy single-link clustering within the name group. + clusters: list[list[dict]] = [] + for node in sorted(nodes, key=lambda n: n["osm_id"]): + for cluster in clusters: + if any(_dist_m(node, member) <= MERGE_METERS for member in cluster): + cluster.append(node) + break + else: + clusters.append([node]) + + for cluster in clusters: + lead = max( + cluster, + key=lambda n: (TYPE_RANK[n["stop_type"]], n["municipality"] != "", -n["osm_id"]), + ) + rows.append( + { + "osm_type": "node", + # lowest id in the cluster -> stable identity across refreshes + "osm_id": min(n["osm_id"] for n in cluster), + "name": lead["name"], + "stop_type": lead["stop_type"], + "municipality": lead["municipality"] + or next((n["municipality"] for n in cluster if n["municipality"]), ""), + "latitude": round(sum(n["latitude"] for n in cluster) / len(cluster), 6), + "longitude": round(sum(n["longitude"] for n in cluster) / len(cluster), 6), + } + ) + + rows.sort(key=lambda r: (normalize_name(r["name"]), r["osm_id"])) + return rows + + +def main() -> None: + payload = fetch_raw() + rows = build_rows(payload) + if len(rows) < 10_000: + raise SystemExit( + f"Only {len(rows)} stops parsed - refusing to overwrite the snapshot. " + "Overpass probably returned a partial result; try again later." + ) + OUT_PATH.parent.mkdir(parents=True, exist_ok=True) + with gzip.open(OUT_PATH, "wt", newline="", encoding="utf-8") as fh: + writer = csv.DictWriter(fh, fieldnames=CSV_FIELDS) + writer.writeheader() + writer.writerows(rows) + by_type: dict[str, int] = {} + for row in rows: + by_type[row["stop_type"]] = by_type.get(row["stop_type"], 0) + 1 + print(f"Wrote {len(rows)} stops to {OUT_PATH}", file=sys.stderr) + print(f" by type: {by_type}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/static/stops.js b/static/stops.js new file mode 100644 index 0000000..8f9c1df --- /dev/null +++ b/static/stops.js @@ -0,0 +1,144 @@ +/* Autocomplete for the origin/destination fields, backed by /api/stops. + Progressive enhancement: without JS the inputs stay plain free-text fields. + A picked stop fills the sibling hidden with its id; editing the text + afterwards clears it again, so unmatched entries save as free text. */ +(function () { + "use strict"; + + var TYPE_LABEL = { + bus: "Bus", + tram: "Straßenbahn", + subway: "U-Bahn", + train: "Zug", + bus_station: "Busbahnhof", + other: "Haltestelle", + }; + + function debounce(fn, ms) { + var t; + return function () { + var args = arguments, self = this; + clearTimeout(t); + t = setTimeout(function () { fn.apply(self, args); }, ms); + }; + } + + function setup(input) { + var wrap = input.closest(".combo"); + var hidden = wrap.querySelector(input.dataset.stopTarget); + var hint = wrap.querySelector("[data-stop-hint]"); + if (!hidden) return; + + var list = document.createElement("div"); + list.className = "combo-list"; + list.hidden = true; + wrap.appendChild(list); + + var items = []; + var active = -1; + var lastQuery = ""; + + function clearMatch() { + hidden.value = ""; + if (hint) { hint.textContent = ""; hint.classList.remove("matched"); } + } + + function markMatch(stop) { + hidden.value = String(stop.id); + if (hint) { + hint.textContent = + "✓ " + stop.name + (stop.municipality ? ", " + stop.municipality : ""); + hint.classList.add("matched"); + } + } + + function close() { list.hidden = true; active = -1; } + + function render() { + list.innerHTML = ""; + if (!items.length) { + var empty = document.createElement("div"); + empty.className = "combo-empty"; + empty.textContent = + "Keine Haltestelle gefunden – Eingabe wird als Freitext gespeichert."; + list.appendChild(empty); + list.hidden = false; + return; + } + items.forEach(function (stop, i) { + var opt = document.createElement("div"); + opt.className = "combo-option" + (i === active ? " active" : ""); + var name = document.createElement("span"); + name.className = "name"; + name.textContent = stop.name; + opt.appendChild(name); + if (stop.municipality) { + var muni = document.createElement("span"); + muni.className = "muni"; + muni.textContent = stop.municipality; + opt.appendChild(muni); + } + var kind = document.createElement("span"); + kind.className = "kind"; + kind.textContent = TYPE_LABEL[stop.type] || ""; + opt.appendChild(kind); + opt.addEventListener("mousedown", function (e) { + e.preventDefault(); + choose(i); + }); + list.appendChild(opt); + }); + list.hidden = false; + } + + function choose(i) { + var stop = items[i]; + if (!stop) return; + input.value = stop.name; + markMatch(stop); + close(); + } + + var search = debounce(function () { + var q = input.value.trim(); + lastQuery = q; + if (q.length < 2) { items = []; close(); return; } + fetch("/api/stops?q=" + encodeURIComponent(q)) + .then(function (r) { return r.ok ? r.json() : []; }) + .then(function (data) { + if (input.value.trim() !== lastQuery) return; + items = data; + active = -1; + render(); + var exact = data.filter(function (s) { + return s.name.toLowerCase() === lastQuery.toLowerCase(); + })[0]; + if (exact) { markMatch(exact); } else { clearMatch(); } + }) + .catch(function () { items = []; close(); }); + }, 180); + + input.setAttribute("autocomplete", "off"); + input.addEventListener("input", function () { clearMatch(); search(); }); + input.addEventListener("focus", function () { if (items.length) render(); }); + input.addEventListener("blur", function () { setTimeout(close, 120); }); + input.addEventListener("keydown", function (e) { + if (list.hidden) return; + if (e.key === "ArrowDown") { + e.preventDefault(); + active = Math.min(active + 1, items.length - 1); + render(); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + active = Math.max(active - 1, 0); + render(); + } else if (e.key === "Enter") { + if (active >= 0) { e.preventDefault(); choose(active); } + } else if (e.key === "Escape") { + close(); + } + }); + } + + document.querySelectorAll("[data-stop-input]").forEach(setup); +})(); diff --git a/stops_import.py b/stops_import.py new file mode 100644 index 0000000..d55ea8a --- /dev/null +++ b/stops_import.py @@ -0,0 +1,134 @@ +"""Load the checked-in Austrian stop snapshot into the ``stops`` table. + +The snapshot (data/stops_at.csv.gz) is produced by scripts/fetch_stops.py from +OpenStreetMap data (© OpenStreetMap contributors, ODbL). Importing needs no +network access. + + flask import-stops # import the checked-in snapshot + flask import-stops --file X # import an alternative CSV(.gz) +""" +from __future__ import annotations + +import csv +import gzip +import io +import unicodedata +from pathlib import Path + +import click +from flask import current_app +from flask.cli import with_appcontext + +from models import Stop, Trip, db + +SNAPSHOT_PATH = Path(__file__).resolve().parent / "data" / "stops_at.csv.gz" + + +def normalize_name(value: str) -> str: + """Lowercase, fold accents and ß for diacritic-insensitive search. + + Mirrors scripts/fetch_stops.py:normalize_name - keep the two in sync. + """ + value = value.strip().lower().replace("ß", "ss") + decomposed = unicodedata.normalize("NFKD", value) + return "".join(ch for ch in decomposed if not unicodedata.combining(ch)) + + +def _open_csv(path: Path) -> io.TextIOBase: + if str(path).endswith(".gz"): + return gzip.open(path, "rt", newline="", encoding="utf-8") + return open(path, "rt", newline="", encoding="utf-8") + + +def import_stops(path: Path | str = SNAPSHOT_PATH) -> dict[str, int]: + """Upsert every row of the snapshot into ``stops`` by (osm_type, osm_id). + + Stops that vanished from the snapshot are removed unless a trip still + references them, so foreign keys from ``trips`` stay valid. + """ + path = Path(path) + if not path.exists(): + raise click.ClickException( + f"Snapshot not found: {path}\nRun scripts/fetch_stops.py first." + ) + + with _open_csv(path) as fh: + rows = list(csv.DictReader(fh)) + if not rows: + raise click.ClickException(f"{path} contains no rows.") + + existing = {(s.osm_type, s.osm_id): s for s in Stop.query.all()} + seen: set[tuple[str, int]] = set() + inserted = updated = 0 + + for row in rows: + key = (row["osm_type"], int(row["osm_id"])) + seen.add(key) + name = row["name"].strip() + fields = dict( + name=name, + name_normalized=normalize_name(name), + stop_type=row["stop_type"] or "other", + municipality=(row.get("municipality") or "").strip() or None, + latitude=float(row["latitude"]), + longitude=float(row["longitude"]), + ) + stop = existing.get(key) + if stop is None: + db.session.add(Stop(osm_type=key[0], osm_id=key[1], **fields)) + inserted += 1 + else: + changed = False + for attr, value in fields.items(): + if getattr(stop, attr) != value: + setattr(stop, attr, value) + changed = True + updated += changed + + referenced = { + sid + for (sid,) in db.session.query(Trip.origin_stop_id).distinct() + if sid is not None + } | { + sid + for (sid,) in db.session.query(Trip.destination_stop_id).distinct() + if sid is not None + } + removed = 0 + for key, stop in existing.items(): + if key not in seen and stop.id not in referenced: + db.session.delete(stop) + removed += 1 + + db.session.commit() + return { + "total": len(rows), + "inserted": inserted, + "updated": updated, + "removed": removed, + } + + +@click.command("import-stops") +@click.option( + "--file", + "file_path", + type=click.Path(exists=True, dir_okay=False), + default=None, + help="CSV(.gz) to import instead of the checked-in snapshot.", +) +@with_appcontext +def import_stops_command(file_path: str | None) -> None: + """Import Austrian public-transport stops into the database.""" + stats = import_stops(file_path or SNAPSHOT_PATH) + click.echo( + "Stops imported: " + f"{stats['total']} in snapshot, " + f"{stats['inserted']} new, {stats['updated']} updated, " + f"{stats['removed']} removed. " + f"Total now: {Stop.query.count()}." + ) + + +def register_cli(app) -> None: + app.cli.add_command(import_stops_command) diff --git a/templates/add_entry.html b/templates/add_entry.html index 045be49..5408843 100644 --- a/templates/add_entry.html +++ b/templates/add_entry.html @@ -18,14 +18,22 @@ -
+
- + + +

-
+
- + + +

@@ -53,3 +61,6 @@
{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/templates/base.html b/templates/base.html index 1b54d0f..bd8d1cb 100644 --- a/templates/base.html +++ b/templates/base.html @@ -134,6 +134,36 @@ th, td { text-align: left; padding: 0.6rem 0.4rem; border-bottom: 1px solid var(--panel-border); font-size: 0.9rem; } th { color: var(--text-muted); font-weight: 600; } .stars { color: #e3b341; white-space: nowrap; } + .stop-dot { color: var(--success); font-size: 0.7rem; vertical-align: middle; } + + .combo { position: relative; display: flex; flex-direction: column; } + .combo-list { + position: absolute; + z-index: 20; + left: 0; + right: 0; + top: calc(100% + 4px); + background: var(--panel); + border: 1px solid var(--panel-border); + border-radius: 6px; + max-height: 260px; + overflow-y: auto; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + } + .combo-option { + display: flex; + align-items: baseline; + gap: 0.5rem; + padding: 0.5rem 0.7rem; + font-size: 0.9rem; + cursor: pointer; + } + .combo-option.active, .combo-option:hover { background: rgba(74, 158, 255, 0.15); } + .combo-option .muni { color: var(--text-muted); font-size: 0.8rem; } + .combo-option .kind { margin-left: auto; font-size: 0.75rem; color: var(--text-muted); white-space: nowrap; } + .combo-empty { padding: 0.5rem 0.7rem; font-size: 0.85rem; color: var(--text-muted); } + .field-hint { font-size: 0.75rem; color: var(--text-muted); margin-top: 0.3rem; min-height: 1em; } + .field-hint.matched { color: var(--success); } @@ -158,5 +188,10 @@ {% endwith %} {% block content %}{% endblock %} + + + {% block scripts %}{% endblock %} diff --git a/templates/dashboard.html b/templates/dashboard.html index e48ce38..8d854a6 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -26,7 +26,10 @@ {{ f.trip_date.strftime("%d.%m.%Y") }} {{ f.transport_mode }} {{ f.line or "–" }} - {{ f.origin }} → {{ f.destination }} + + {{ f.origin }}{% if f.origin_stop %}{% endif %} + → {{ f.destination }}{% if f.destination_stop %}{% endif %} + {{ "★" * f.rating }}{{ "☆" * (5 - f.rating) }}