map view (Leaflet + OSM), iOS switches, Alarm → Benachrichtigungen

* flats: new lat/lng columns (migration v3); alert geocodes every new flat
  through googlemaps and ships coords in the payload
* web: CSP extended for unpkg (leaflet.css) + tile.openstreetmap.org
* Wohnungen tab: Liste/Karte view toggle (segmented, CSS-only via :has(),
  selection persisted in localStorage). Karte shows passende flats as Pins
  on an OSM tile map; Popup per Pin mit Adresse, Zimmer/m²/€ und Link
* Top-strip toggles are now proper iOS-style toggle switches (single
  rounded knob sliding in a pill, red when on), no descriptive subtitle
* Alarm-Karte verlinkt jetzt auf /einstellungen/benachrichtigungen
  (Filter-Karte bleibt /einstellungen/filter)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Moritz 2026-04-21 12:02:40 +02:00
parent d9468f6814
commit 376551213a
8 changed files with 239 additions and 83 deletions

View file

@ -28,6 +28,7 @@ class Flat:
self.id = self.link # we could use data.get('id', None) but link is easier to debug self.id = self.link # we could use data.get('id', None) but link is easier to debug
self.gmaps = maps.Maps() self.gmaps = maps.Maps()
self._connectivity = None self._connectivity = None
self._coords = None
self.address_link_gmaps = f"https://www.google.com/maps/search/?api=1&query={quote(self.address)}" self.address_link_gmaps = f"https://www.google.com/maps/search/?api=1&query={quote(self.address)}"
def __str__(self): def __str__(self):
@ -59,6 +60,12 @@ class Flat:
self._connectivity = self.gmaps.calculate_score(self.address) self._connectivity = self.gmaps.calculate_score(self.address)
return self._connectivity 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 @property
def display_address(self): def display_address(self):
if ',' in self.address: if ',' in self.address:

View file

@ -32,6 +32,7 @@ class FlatAlerter:
def _flat_payload(self, flat: Flat) -> dict: def _flat_payload(self, flat: Flat) -> dict:
c = flat.connectivity c = flat.connectivity
lat, lng = flat.coords
return { return {
"id": flat.id, "id": flat.id,
"link": flat.link, "link": flat.link,
@ -53,6 +54,8 @@ class FlatAlerter:
"energy_value": flat.energy_value, "energy_value": flat.energy_value,
"energy_certificate": flat.energy_certificate, "energy_certificate": flat.energy_certificate,
"address_link_gmaps": flat.address_link_gmaps, "address_link_gmaps": flat.address_link_gmaps,
"lat": lat,
"lng": lng,
"connectivity": { "connectivity": {
"morning_time": c.get("morning_time", 0), "morning_time": c.get("morning_time", 0),
"morning_transfers": c.get("morning_transfers", 0), "morning_transfers": c.get("morning_transfers", 0),

View file

@ -23,6 +23,20 @@ class Maps:
def __init__(self): def __init__(self):
self.gmaps = googlemaps.Client(key=GMAPS_API_KEY) 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): def _get_next_weekday(self, date, weekday):
days_ahead = weekday - date.weekday() days_ahead = weekday - date.weekday()
if days_ahead <= 0: if days_ahead <= 0:

View file

@ -132,8 +132,9 @@ async def security_headers(request: Request, call_next):
"Content-Security-Policy", "Content-Security-Policy",
"default-src 'self'; " "default-src 'self'; "
"script-src 'self' https://cdn.tailwindcss.com https://unpkg.com; " "script-src 'self' https://cdn.tailwindcss.com https://unpkg.com; "
"style-src 'self' https://cdn.tailwindcss.com 'unsafe-inline'; " "style-src 'self' https://cdn.tailwindcss.com https://unpkg.com 'unsafe-inline'; "
"img-src 'self' data:; connect-src 'self'; frame-ancestors 'none';" "img-src 'self' data: https://*.tile.openstreetmap.org https://tile.openstreetmap.org; "
"connect-src 'self'; frame-ancestors 'none';"
) )
return resp return resp
@ -394,8 +395,22 @@ def _wohnungen_context(user) -> dict:
allowed, reason = _manual_apply_allowed() allowed, reason = _manual_apply_allowed()
alert_label, alert_chip = _alert_status(filters_row, notif_row) alert_label, alert_chip = _alert_status(filters_row, notif_row)
has_running = _has_running_application(uid) 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 { return {
"flats": flats_view, "flats": flats_view,
"map_points": map_points,
"alert_label": alert_label, "alert_label": alert_label,
"alert_chip": alert_chip, "alert_chip": alert_chip,
"filter_summary": _filter_summary(filters_row), "filter_summary": _filter_summary(filters_row),
@ -462,7 +477,7 @@ async def action_save_filters(
@app.post("/actions/auto-apply") @app.post("/actions/auto-apply")
async def action_auto_apply( async def action_auto_apply(
request: Request, request: Request,
value: str = Form(...), value: str = Form(default="off"),
csrf: str = Form(...), csrf: str = Form(...),
user=Depends(require_user), user=Depends(require_user),
): ):
@ -834,7 +849,7 @@ async def action_password(
@app.post("/actions/submit-forms") @app.post("/actions/submit-forms")
async def action_submit_forms( async def action_submit_forms(
request: Request, request: Request,
value: str = Form(...), value: str = Form(default="off"),
csrf: str = Form(...), csrf: str = Form(...),
user=Depends(require_user), user=Depends(require_user),
): ):

View file

@ -180,6 +180,11 @@ MIGRATIONS: list[str] = [
value TEXT NOT NULL 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( """INSERT INTO flats(
id, link, address, rooms, size, total_rent, sqm_price, year_built, wbs, id, link, address, rooms, size, total_rent, sqm_price, year_built, wbs,
connectivity_morning_time, connectivity_night_time, address_link_gmaps, connectivity_morning_time, connectivity_night_time, address_link_gmaps,
payload_json, discovered_at payload_json, discovered_at, lat, lng
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
( (
flat_id, payload.get("link", ""), payload.get("address", ""), flat_id, payload.get("link", ""), payload.get("address", ""),
payload.get("rooms"), payload.get("size"), payload.get("total_rent"), 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"), payload.get("address_link_gmaps"),
json.dumps(payload, default=str), json.dumps(payload, default=str),
now_iso(), now_iso(),
payload.get("lat"), payload.get("lng"),
), ),
) )
return True return True

94
web/static/map.js Normal file
View file

@ -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(/</g, "&lt;");
const safeLink = (f.link || "#").replace(/"/g, "&quot;");
m.bindPopup(
`<b>${safeAddr}</b>` +
(meta ? `<br>${meta}` : "") +
`<br><a href="${safeLink}" target="_blank" rel="noopener">Zur Anzeige →</a>`,
);
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);

View file

@ -7,7 +7,7 @@
<!-- Reihe 1: Info-Kacheln Alarm + Filter --> <!-- Reihe 1: Info-Kacheln Alarm + Filter -->
<section class="grid grid-cols-2 gap-3"> <section class="grid grid-cols-2 gap-3">
<a class="card px-4 py-2.5 flex flex-col gap-0.5 hover:bg-[#f6fafd]" href="/einstellungen/filter"> <a class="card px-4 py-2.5 flex flex-col gap-0.5 hover:bg-[#f6fafd]" href="/einstellungen/benachrichtigungen">
<div class="text-[11px] uppercase tracking-wide text-slate-500">Alarm</div> <div class="text-[11px] uppercase tracking-wide text-slate-500">Alarm</div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="chip chip-{{ alert_chip }}">{{ alert_label }}</span> <span class="chip chip-{{ alert_chip }}">{{ alert_label }}</span>
@ -21,68 +21,36 @@
<!-- Reihe 2: Schalter Automatisch bewerben + Final absenden --> <!-- Reihe 2: Schalter Automatisch bewerben + Final absenden -->
<section class="grid grid-cols-1 md:grid-cols-2 gap-3"> <section class="grid grid-cols-1 md:grid-cols-2 gap-3">
<!-- Automatisch bewerben -->
<form class="card p-4 flex items-center justify-between gap-3"> <form class="card p-4 flex items-center justify-between gap-3">
<input type="hidden" name="csrf" value="{{ csrf }}"> <input type="hidden" name="csrf" value="{{ csrf }}">
<div class="flex flex-col gap-0.5"> <div class="text-sm font-medium">Automatisch bewerben</div>
<div class="text-[11px] uppercase tracking-wide text-slate-500">Automatisch bewerben</div> <label class="switch warn">
<div class="text-xs text-slate-500">bei Match ohne Nachfrage bewerben</div> <input type="checkbox" name="value" value="on"
</div>
<div class="toggle warn">
<label>
<input type="radio" name="value" value="off"
hx-post="/actions/auto-apply" hx-post="/actions/auto-apply"
hx-trigger="change" hx-trigger="change"
hx-include="closest form" hx-include="closest form"
hx-target="#wohnungen-body" hx-target="#wohnungen-body"
hx-swap="outerHTML" hx-swap="outerHTML"
{% if not auto_apply_enabled %}checked{% endif %}> {% if not auto_apply_enabled %}hx-confirm="Automatisches Bewerben einschalten? Bei jedem passenden Flat wird automatisch beworben."{% endif %}
Aus
</label>
<label>
<input type="radio" name="value" value="on"
hx-post="/actions/auto-apply"
hx-trigger="change"
hx-include="closest form"
hx-target="#wohnungen-body"
hx-swap="outerHTML"
hx-confirm="Automatisches Bewerben einschalten? Bei jedem passenden Flat wird automatisch beworben."
{% if auto_apply_enabled %}checked{% endif %}> {% if auto_apply_enabled %}checked{% endif %}>
An <span class="switch-visual"></span>
</label> </label>
</div>
</form> </form>
<!-- Final absenden (inverse of submit_forms: on=real, off=trocken) -->
<form class="card p-4 flex items-center justify-between gap-3"> <form class="card p-4 flex items-center justify-between gap-3">
<input type="hidden" name="csrf" value="{{ csrf }}"> <input type="hidden" name="csrf" value="{{ csrf }}">
<div class="flex flex-col gap-0.5"> <div class="text-sm font-medium">Final absenden</div>
<div class="text-[11px] uppercase tracking-wide text-slate-500">Final absenden</div> <label class="switch warn">
<div class="text-xs text-slate-500">aus = Formular ausfüllen, nicht abschicken</div> <input type="checkbox" name="value" value="on"
</div>
<div class="toggle warn">
<label>
<input type="radio" name="value" value="off"
hx-post="/actions/submit-forms" hx-post="/actions/submit-forms"
hx-trigger="change" hx-trigger="change"
hx-include="closest form" hx-include="closest form"
hx-target="#wohnungen-body" hx-target="#wohnungen-body"
hx-swap="outerHTML" hx-swap="outerHTML"
{% if not submit_forms %}checked{% endif %}> {% if not submit_forms %}hx-confirm="Final absenden einschalten? Formulare werden dann WIRKLICH abgeschickt!"{% endif %}
Aus
</label>
<label>
<input type="radio" name="value" value="on"
hx-post="/actions/submit-forms"
hx-trigger="change"
hx-include="closest form"
hx-target="#wohnungen-body"
hx-swap="outerHTML"
hx-confirm="Final absenden einschalten? Formulare werden dann WIRKLICH abgeschickt!"
{% if submit_forms %}checked{% endif %}> {% if submit_forms %}checked{% endif %}>
An <span class="switch-visual"></span>
</label> </label>
</div>
</form> </form>
</section> </section>
@ -107,17 +75,42 @@
</div> </div>
{% endif %} {% endif %}
<!-- Liste passender Wohnungen --> <!-- Header + View-Toggle (Liste/Karte) -->
<section class="card"> <section class="flex items-center justify-between gap-4 flex-wrap">
<div class="flex items-center justify-between px-4 py-3 border-b border-soft gap-4 flex-wrap">
<h2 class="font-semibold">Passende Wohnungen auf inberlinwohnen.de</h2> <h2 class="font-semibold">Passende Wohnungen auf inberlinwohnen.de</h2>
<div class="text-xs text-slate-500 flex gap-3 items-center"> <div class="flex items-center gap-3 text-xs text-slate-500">
<span>{{ flats|length }} gefunden</span> <span>{{ flats|length }} gefunden</span>
{% if next_scrape_utc %} {% if next_scrape_utc %}
<span>· nächste Aktualisierung <span data-countdown-utc="{{ next_scrape_utc }}"></span></span> <span>· nächste Aktualisierung <span data-countdown-utc="{{ next_scrape_utc }}"></span></span>
{% endif %} {% endif %}
<div class="view-toggle ml-2">
<label>
<input type="radio" name="view_mode" id="v_list" value="list" checked>
Liste
</label>
<label>
<input type="radio" name="view_mode" id="v_map" value="map">
Karte
</label>
</div> </div>
</div> </div>
</section>
<!-- Karte -->
<section class="view-map">
<div class="card p-3">
<div id="flats-map" data-flats='{{ map_points | tojson }}'></div>
{% if not map_points %}
<p class="mt-3 text-xs text-slate-500">
Keine Koordinaten für passende Wohnungen vorhanden —
entweder sind noch keine neuen Flats geocoded worden oder die Filter lassen noch nichts durch.
</p>
{% endif %}
</div>
</section>
<!-- Liste -->
<section class="view-list card">
<div class="divide-y divide-soft"> <div class="divide-y divide-soft">
{% for item in flats %} {% for item in flats %}
{% set f = item.row %} {% set f = item.row %}

View file

@ -7,7 +7,12 @@
<title>{% block title %}lazyflat{% endblock %}</title> <title>{% block title %}lazyflat{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@2.0.3"></script> <script src="https://unpkg.com/htmx.org@2.0.3"></script>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script src="/static/app.js" defer></script> <script src="/static/app.js" defer></script>
<script src="/static/map.js" defer></script>
<style> <style>
:root { :root {
--bg-from: #e4f0fb; --bg-to: #f7fbfe; --bg-from: #e4f0fb; --bg-to: #f7fbfe;
@ -47,17 +52,36 @@
.chip-warn { background: #fff4dd; color: #a36a1f; border: 1px solid #f5d48b; } .chip-warn { background: #fff4dd; color: #a36a1f; border: 1px solid #f5d48b; }
.chip-bad { background: #fde6e9; color: #b8404e; border: 1px solid #f5b5bf; } .chip-bad { background: #fde6e9; color: #b8404e; border: 1px solid #f5b5bf; }
.chip-info { background: #e3effc; color: #1f5f99; border: 1px solid #b6d4f0; } .chip-info { background: #e3effc; color: #1f5f99; border: 1px solid #b6d4f0; }
/* Segmented toggle (An/Aus Kippschalter) */ /* iOS-style toggle switch */
.toggle { display: inline-flex; border: 1px solid var(--border); border-radius: 999px; .switch { position: relative; display: inline-block; width: 46px; height: 26px;
overflow: hidden; background: var(--surface); font-size: 0.85rem; font-weight: 500; } flex-shrink: 0; }
.toggle label { padding: 0.45rem 1.1rem; cursor: pointer; user-select: none; .switch input { opacity: 0; width: 0; height: 0; position: absolute; }
.switch-visual { position: absolute; cursor: pointer; inset: 0;
background: #cfd9e6; border-radius: 999px;
transition: background .2s; }
.switch-visual::before { content: ""; position: absolute; width: 20px; height: 20px;
left: 3px; top: 3px; background: #fff; border-radius: 50%;
box-shadow: 0 1px 3px rgba(16,37,63,0.25);
transition: transform .2s; }
.switch input:checked + .switch-visual { background: var(--primary); }
.switch input:checked + .switch-visual::before { transform: translateX(20px); }
.switch.warn input:checked + .switch-visual { background: var(--danger); }
.switch input:focus-visible + .switch-visual { box-shadow: 0 0 0 3px rgba(47,138,224,.25); }
/* View toggle (Liste / Karte) — segmented pill, CSS-only via :has() */
.view-toggle { display: inline-flex; border: 1px solid var(--border);
border-radius: 999px; overflow: hidden; background: var(--surface);
font-size: 0.85rem; font-weight: 500; }
.view-toggle label { padding: 0.35rem 0.95rem; cursor: pointer; user-select: none;
color: var(--muted); transition: background .15s, color .15s; } color: var(--muted); transition: background .15s, color .15s; }
.toggle label + label { border-left: 1px solid var(--border); } .view-toggle input { position: absolute; opacity: 0; pointer-events: none;
.toggle label input[type="radio"] { position: absolute; opacity: 0; pointer-events: none;
width: 0; height: 0; } width: 0; height: 0; }
.toggle label:hover { color: var(--text); background: var(--ghost); } .view-toggle label:hover { color: var(--text); background: var(--ghost); }
.toggle label:has(input:checked) { background: var(--primary); color: #fff; } .view-toggle label:has(input:checked) { background: var(--primary); color: #fff; }
.toggle.warn label:has(input[value="on"]:checked) { background: var(--danger); } .view-map { display: none; }
body:has(#v_map:checked) .view-list { display: none; }
body:has(#v_map:checked) .view-map { display: block; }
#flats-map { height: 520px; border-radius: 10px; }
.brand-dot { .brand-dot {
width: 2rem; height: 2rem; border-radius: 10px; width: 2rem; height: 2rem; border-radius: 10px;
background: linear-gradient(135deg, #66b7f2 0%, #2f8ae0 60%, #fbd76b 100%); background: linear-gradient(135deg, #66b7f2 0%, #2f8ae0 60%, #fbd76b 100%);