diff --git a/alert/flat.py b/alert/flat.py index f42ade5..54958b8 100644 --- a/alert/flat.py +++ b/alert/flat.py @@ -28,6 +28,7 @@ class Flat: self.id = self.link # we could use data.get('id', None) but link is easier to debug self.gmaps = maps.Maps() self._connectivity = None + self._coords = None self.address_link_gmaps = f"https://www.google.com/maps/search/?api=1&query={quote(self.address)}" def __str__(self): @@ -59,6 +60,12 @@ class Flat: self._connectivity = self.gmaps.calculate_score(self.address) return self._connectivity + @property + def coords(self): + if self._coords is None: + self._coords = self.gmaps.geocode(self.address) or (None, None) + return self._coords + @property def display_address(self): if ',' in self.address: diff --git a/alert/main.py b/alert/main.py index 10324e8..d56a357 100644 --- a/alert/main.py +++ b/alert/main.py @@ -32,6 +32,7 @@ class FlatAlerter: def _flat_payload(self, flat: Flat) -> dict: c = flat.connectivity + lat, lng = flat.coords return { "id": flat.id, "link": flat.link, @@ -53,6 +54,8 @@ class FlatAlerter: "energy_value": flat.energy_value, "energy_certificate": flat.energy_certificate, "address_link_gmaps": flat.address_link_gmaps, + "lat": lat, + "lng": lng, "connectivity": { "morning_time": c.get("morning_time", 0), "morning_transfers": c.get("morning_transfers", 0), diff --git a/alert/maps.py b/alert/maps.py index b024658..f07ce4c 100644 --- a/alert/maps.py +++ b/alert/maps.py @@ -23,6 +23,20 @@ class Maps: def __init__(self): self.gmaps = googlemaps.Client(key=GMAPS_API_KEY) + def geocode(self, address): + """Return (lat, lng) or None for a Berlin address string.""" + if not address: + return None + try: + res = self.gmaps.geocode(f"{address}, Berlin, Germany") + if not res: + return None + loc = res[0]["geometry"]["location"] + return (float(loc["lat"]), float(loc["lng"])) + except Exception as e: + logger.warning("geocode failed for %r: %s", address, e) + return None + def _get_next_weekday(self, date, weekday): days_ahead = weekday - date.weekday() if days_ahead <= 0: diff --git a/web/app.py b/web/app.py index b6b1023..96226c6 100644 --- a/web/app.py +++ b/web/app.py @@ -132,8 +132,9 @@ async def security_headers(request: Request, call_next): "Content-Security-Policy", "default-src 'self'; " "script-src 'self' https://cdn.tailwindcss.com https://unpkg.com; " - "style-src 'self' https://cdn.tailwindcss.com 'unsafe-inline'; " - "img-src 'self' data:; connect-src 'self'; frame-ancestors 'none';" + "style-src 'self' https://cdn.tailwindcss.com https://unpkg.com 'unsafe-inline'; " + "img-src 'self' data: https://*.tile.openstreetmap.org https://tile.openstreetmap.org; " + "connect-src 'self'; frame-ancestors 'none';" ) return resp @@ -394,8 +395,22 @@ def _wohnungen_context(user) -> dict: allowed, reason = _manual_apply_allowed() alert_label, alert_chip = _alert_status(filters_row, notif_row) has_running = _has_running_application(uid) + map_points = [] + for item in flats_view: + f = item["row"] + if f["lat"] is None or f["lng"] is None: + continue + map_points.append({ + "lat": f["lat"], "lng": f["lng"], + "address": f["address"] or f["link"], + "link": f["link"], + "rent": f["total_rent"], + "rooms": f["rooms"], + "size": f["size"], + }) return { "flats": flats_view, + "map_points": map_points, "alert_label": alert_label, "alert_chip": alert_chip, "filter_summary": _filter_summary(filters_row), @@ -462,7 +477,7 @@ async def action_save_filters( @app.post("/actions/auto-apply") async def action_auto_apply( request: Request, - value: str = Form(...), + value: str = Form(default="off"), csrf: str = Form(...), user=Depends(require_user), ): @@ -834,7 +849,7 @@ async def action_password( @app.post("/actions/submit-forms") async def action_submit_forms( request: Request, - value: str = Form(...), + value: str = Form(default="off"), csrf: str = Form(...), user=Depends(require_user), ): diff --git a/web/db.py b/web/db.py index 847c77a..a714b90 100644 --- a/web/db.py +++ b/web/db.py @@ -180,6 +180,11 @@ MIGRATIONS: list[str] = [ value TEXT NOT NULL ); """, + # 0003: lat/lng for map view + """ + ALTER TABLE flats ADD COLUMN lat REAL; + ALTER TABLE flats ADD COLUMN lng REAL; + """, ] @@ -388,8 +393,8 @@ def upsert_flat(payload: dict) -> bool: """INSERT INTO flats( id, link, address, rooms, size, total_rent, sqm_price, year_built, wbs, connectivity_morning_time, connectivity_night_time, address_link_gmaps, - payload_json, discovered_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + payload_json, discovered_at, lat, lng + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( flat_id, payload.get("link", ""), payload.get("address", ""), payload.get("rooms"), payload.get("size"), payload.get("total_rent"), @@ -399,6 +404,7 @@ def upsert_flat(payload: dict) -> bool: payload.get("address_link_gmaps"), json.dumps(payload, default=str), now_iso(), + payload.get("lat"), payload.get("lng"), ), ) return True diff --git a/web/static/map.js b/web/static/map.js new file mode 100644 index 0000000..771b193 --- /dev/null +++ b/web/static/map.js @@ -0,0 +1,94 @@ +// lazyflat — Leaflet flat map +// A single Leaflet map instance; re-initialised after every HTMX swap of +// the Wohnungen body. Also flushes size when the view toggle flips from +// list to map (Leaflet needs invalidateSize on a hidden-then-shown map). + +let mapInstance = null; +const BERLIN_CENTER = [52.52, 13.405]; +const BERLIN_ZOOM = 11; + +function initFlatsMap() { + const el = document.getElementById("flats-map"); + if (!el || typeof L === "undefined") return; + if (mapInstance) { + try { mapInstance.remove(); } catch (e) {} + mapInstance = null; + } + mapInstance = L.map(el).setView(BERLIN_CENTER, BERLIN_ZOOM); + L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { + attribution: "© OpenStreetMap", + maxZoom: 18, + }).addTo(mapInstance); + + let data = []; + try { + data = JSON.parse(el.dataset.flats || "[]"); + } catch (e) { + console.warn("flats-map: bad JSON in data-flats", e); + } + + const bounds = []; + data.forEach((f) => { + if (typeof f.lat !== "number" || typeof f.lng !== "number") return; + const m = L.marker([f.lat, f.lng]).addTo(mapInstance); + const rent = f.rent ? Math.round(f.rent) + " €" : ""; + const rooms = f.rooms ? f.rooms + " Zi" : ""; + const size = f.size ? Math.round(f.size) + " m²" : ""; + const meta = [rooms, size, rent].filter(Boolean).join(" · "); + const safeAddr = (f.address || "").replace(/${safeAddr}` + + (meta ? `
${meta}` : "") + + `
Zur Anzeige →`, + ); + bounds.push([f.lat, f.lng]); + }); + if (bounds.length === 1) { + mapInstance.setView(bounds[0], 14); + } else if (bounds.length > 1) { + mapInstance.fitBounds(bounds, { padding: [30, 30] }); + } +} + +function flushMapSize() { + if (mapInstance) { + setTimeout(() => mapInstance.invalidateSize(), 50); + } +} + +function wireViewToggle() { + document.querySelectorAll('input[name="view_mode"]').forEach((r) => { + if (r.dataset.wired === "1") return; + r.dataset.wired = "1"; + r.addEventListener("change", (e) => { + try { + localStorage.setItem("lazyflat_view_mode", e.target.value); + } catch (err) {} + flushMapSize(); + }); + }); +} + +function restoreView() { + let stored = null; + try { + stored = localStorage.getItem("lazyflat_view_mode"); + } catch (err) {} + if (!stored) return; + const el = document.querySelector( + `input[name="view_mode"][value="${stored}"]`, + ); + if (el && !el.checked) { + el.checked = true; + flushMapSize(); + } +} + +function onReady() { + initFlatsMap(); + wireViewToggle(); + restoreView(); +} +document.addEventListener("DOMContentLoaded", onReady); +document.body && document.body.addEventListener("htmx:afterSwap", onReady); diff --git a/web/templates/_wohnungen_body.html b/web/templates/_wohnungen_body.html index 6ca7852..2e6a380 100644 --- a/web/templates/_wohnungen_body.html +++ b/web/templates/_wohnungen_body.html @@ -7,7 +7,7 @@
- +
Alarm
{{ alert_label }} @@ -21,68 +21,36 @@
-
-
-
Automatisch bewerben
-
bei Match ohne Nachfrage bewerben
-
-
- - -
+
Automatisch bewerben
+
-
-
-
Final absenden
-
aus = Formular ausfüllen, nicht abschicken
-
-
- - -
+
Final absenden
+
@@ -107,17 +75,42 @@
{% endif %} - -
-
-

Passende Wohnungen auf inberlinwohnen.de

-
- {{ flats|length }} gefunden - {% if next_scrape_utc %} - · nächste Aktualisierung - {% endif %} + +
+

Passende Wohnungen auf inberlinwohnen.de

+
+ {{ flats|length }} gefunden + {% if next_scrape_utc %} + · nächste Aktualisierung + {% endif %} +
+ +
+
+ + +
+
+
+ {% if not map_points %} +

+ Keine Koordinaten für passende Wohnungen vorhanden — + entweder sind noch keine neuen Flats geocoded worden oder die Filter lassen noch nichts durch. +

+ {% endif %} +
+
+ + +
{% for item in flats %} {% set f = item.row %} diff --git a/web/templates/base.html b/web/templates/base.html index 8ce2784..14cee1f 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -7,7 +7,12 @@ {% block title %}lazyflat{% endblock %} + + +