wohnungen: preserve map across HTMX polls, add rejected section, drop €/m²
- #flats-map uses hx-preserve; marker data moved to <script type="application/json"> sibling that diffs+updates instead of rebuilding Leaflet every poll (fixes whitescreen + tiles rendering outside the card) - upsert_flat backfills lat/lng on existing rows missing coords (older flats scraped before the lat/lng migration now appear on the map once alert re-submits) - collapsible "Abgelehnte Wohnungen" section at the bottom with Wiederherstellen - remove €/m² column from the list Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4fd0b50a43
commit
51b6b02b24
4 changed files with 155 additions and 45 deletions
17
web/app.py
17
web/app.py
|
|
@ -404,6 +404,8 @@ def _wohnungen_context(user) -> dict:
|
||||||
last = db.last_application_for_flat(uid, f["id"])
|
last = db.last_application_for_flat(uid, f["id"])
|
||||||
flats_view.append({"row": f, "last": last})
|
flats_view.append({"row": f, "last": last})
|
||||||
|
|
||||||
|
rejected_view = db.rejected_flats(uid)
|
||||||
|
|
||||||
allowed, reason = _manual_apply_allowed()
|
allowed, reason = _manual_apply_allowed()
|
||||||
alert_label, alert_chip = _alert_status(notif_row)
|
alert_label, alert_chip = _alert_status(notif_row)
|
||||||
has_running = _has_running_application(uid)
|
has_running = _has_running_application(uid)
|
||||||
|
|
@ -422,6 +424,7 @@ def _wohnungen_context(user) -> dict:
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
"flats": flats_view,
|
"flats": flats_view,
|
||||||
|
"rejected_flats": rejected_view,
|
||||||
"map_points": map_points,
|
"map_points": map_points,
|
||||||
"has_filters": _has_filters(filters_row),
|
"has_filters": _has_filters(filters_row),
|
||||||
"alert_label": alert_label,
|
"alert_label": alert_label,
|
||||||
|
|
@ -548,6 +551,20 @@ async def action_reject(
|
||||||
return _wohnungen_partial_or_redirect(request, user)
|
return _wohnungen_partial_or_redirect(request, user)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/actions/unreject")
|
||||||
|
async def action_unreject(
|
||||||
|
request: Request,
|
||||||
|
flat_id: str = Form(...),
|
||||||
|
csrf: str = Form(...),
|
||||||
|
user=Depends(require_user),
|
||||||
|
):
|
||||||
|
require_csrf(user["id"], csrf)
|
||||||
|
db.unreject_flat(user["id"], flat_id)
|
||||||
|
db.log_audit(user["username"], "flat.unrejected", f"flat_id={flat_id}",
|
||||||
|
user_id=user["id"], ip=client_ip(request))
|
||||||
|
return _wohnungen_partial_or_redirect(request, user)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Tab: Bewerbungen
|
# Tab: Bewerbungen
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
21
web/db.py
21
web/db.py
|
|
@ -395,8 +395,17 @@ def update_preferences(user_id: int, data: dict) -> None:
|
||||||
def upsert_flat(payload: dict) -> bool:
|
def upsert_flat(payload: dict) -> bool:
|
||||||
flat_id = str(payload["id"])
|
flat_id = str(payload["id"])
|
||||||
with _lock:
|
with _lock:
|
||||||
existing = _conn.execute("SELECT id FROM flats WHERE id = ?", (flat_id,)).fetchone()
|
existing = _conn.execute(
|
||||||
|
"SELECT id, lat, lng FROM flats WHERE id = ?", (flat_id,)
|
||||||
|
).fetchone()
|
||||||
if existing:
|
if existing:
|
||||||
|
# Backfill coords on old rows that pre-date the lat/lng migration.
|
||||||
|
if (existing["lat"] is None or existing["lng"] is None) \
|
||||||
|
and payload.get("lat") is not None and payload.get("lng") is not None:
|
||||||
|
_conn.execute(
|
||||||
|
"UPDATE flats SET lat = ?, lng = ? WHERE id = ?",
|
||||||
|
(payload["lat"], payload["lng"], flat_id),
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
c = payload.get("connectivity") or {}
|
c = payload.get("connectivity") or {}
|
||||||
_conn.execute(
|
_conn.execute(
|
||||||
|
|
@ -508,6 +517,16 @@ def rejected_flat_ids(user_id: int) -> set[str]:
|
||||||
return {row["flat_id"] for row in rows}
|
return {row["flat_id"] for row in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def rejected_flats(user_id: int, limit: int = 200) -> list[sqlite3.Row]:
|
||||||
|
return list(_conn.execute(
|
||||||
|
"""SELECT f.*, r.rejected_at
|
||||||
|
FROM flat_rejections r JOIN flats f ON f.id = r.flat_id
|
||||||
|
WHERE r.user_id = ?
|
||||||
|
ORDER BY r.rejected_at DESC LIMIT ?""",
|
||||||
|
(user_id, limit),
|
||||||
|
).fetchall())
|
||||||
|
|
||||||
|
|
||||||
def last_application_for_flat(user_id: int, flat_id: str) -> Optional[sqlite3.Row]:
|
def last_application_for_flat(user_id: int, flat_id: str) -> Optional[sqlite3.Row]:
|
||||||
return _conn.execute(
|
return _conn.execute(
|
||||||
"""SELECT * FROM applications
|
"""SELECT * FROM applications
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,75 @@
|
||||||
// lazyflat — Leaflet flat map
|
// lazyflat — Leaflet flat map.
|
||||||
// Initialised LAZILY: we only build the Leaflet instance when the container
|
//
|
||||||
// actually has a rendered size (> 0 height). Building it on a hidden 0×0
|
// Two important properties:
|
||||||
// container leaves Leaflet in a state where tiles never load.
|
// 1. The map must only be *built* once the container has a real size, because
|
||||||
|
// Leaflet initialised on a hidden 0×0 element never loads its tiles.
|
||||||
|
// 2. The `#wohnungen-body` partial is re-swapped by HTMX every few seconds.
|
||||||
|
// To avoid rebuilding Leaflet (and all its tile/marker state) on every
|
||||||
|
// poll — which caused the whitescreen + out-of-frame glitches — the map
|
||||||
|
// container itself is preserved across swaps via `hx-preserve`, and the
|
||||||
|
// marker data is pushed in through a sibling <script id="flats-map-data">
|
||||||
|
// element that DOES get swapped. On each swap we diff markers against the
|
||||||
|
// fresh data and update in place.
|
||||||
|
|
||||||
let mapInstance = null;
|
let mapInstance = null;
|
||||||
|
let markerLayer = null;
|
||||||
|
let currentFingerprint = "";
|
||||||
const BERLIN_CENTER = [52.52, 13.405];
|
const BERLIN_CENTER = [52.52, 13.405];
|
||||||
const BERLIN_ZOOM = 11;
|
const BERLIN_ZOOM = 11;
|
||||||
|
|
||||||
function buildMap(el) {
|
function readMapData() {
|
||||||
if (mapInstance) {
|
const script = document.getElementById("flats-map-data");
|
||||||
try { mapInstance.remove(); } catch (e) {}
|
if (!script) return [];
|
||||||
mapInstance = null;
|
try {
|
||||||
|
const data = JSON.parse(script.textContent || "[]");
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fingerprintOf(data) {
|
||||||
|
return data
|
||||||
|
.map((f) => `${f.lat},${f.lng},${f.address || ""},${f.rent || ""}`)
|
||||||
|
.join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkers(data) {
|
||||||
|
if (!mapInstance) return;
|
||||||
|
const fp = fingerprintOf(data);
|
||||||
|
if (fp === currentFingerprint) return;
|
||||||
|
currentFingerprint = fp;
|
||||||
|
|
||||||
|
if (markerLayer) {
|
||||||
|
markerLayer.clearLayers();
|
||||||
|
} else {
|
||||||
|
markerLayer = L.layerGroup().addTo(mapInstance);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bounds = [];
|
||||||
|
data.forEach((f) => {
|
||||||
|
if (typeof f.lat !== "number" || typeof f.lng !== "number") return;
|
||||||
|
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, "<");
|
||||||
|
const safeLink = (f.link || "#").replace(/"/g, """);
|
||||||
|
L.marker([f.lat, f.lng]).addTo(markerLayer).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 buildMap(el) {
|
||||||
mapInstance = L.map(el, {
|
mapInstance = L.map(el, {
|
||||||
zoomControl: false,
|
zoomControl: false,
|
||||||
scrollWheelZoom: false,
|
scrollWheelZoom: false,
|
||||||
|
|
@ -25,45 +83,30 @@ function buildMap(el) {
|
||||||
maxZoom: 18,
|
maxZoom: 18,
|
||||||
subdomains: "abc",
|
subdomains: "abc",
|
||||||
}).addTo(mapInstance);
|
}).addTo(mapInstance);
|
||||||
|
markerLayer = L.layerGroup().addTo(mapInstance);
|
||||||
let data = [];
|
|
||||||
try { data = JSON.parse(el.dataset.flats || "[]"); } catch (e) {}
|
|
||||||
const bounds = [];
|
|
||||||
data.forEach((f) => {
|
|
||||||
if (typeof f.lat !== "number" || typeof f.lng !== "number") return;
|
|
||||||
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, "<");
|
|
||||||
const safeLink = (f.link || "#").replace(/"/g, """);
|
|
||||||
L.marker([f.lat, f.lng]).addTo(mapInstance).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 ensureMap() {
|
function ensureMap() {
|
||||||
const el = document.getElementById("flats-map");
|
const el = document.getElementById("flats-map");
|
||||||
if (!el || typeof L === "undefined") return;
|
if (!el || typeof L === "undefined") return;
|
||||||
|
|
||||||
// Container not actually visible yet → bail, we'll retry when the view toggles.
|
// Hidden (0×0) container → tiles would never load; retry after view toggle.
|
||||||
if (el.clientHeight < 10) return;
|
if (el.clientHeight < 10) return;
|
||||||
|
|
||||||
// Existing instance bound to THIS element → just recheck size.
|
if (!mapInstance) {
|
||||||
if (mapInstance && mapInstance._container === el) {
|
buildMap(el);
|
||||||
|
} else if (mapInstance._container !== el) {
|
||||||
|
// Container node changed (shouldn't happen thanks to hx-preserve, but
|
||||||
|
// defensive: rebuild so Leaflet rebinds to the live element).
|
||||||
|
try { mapInstance.remove(); } catch (e) {}
|
||||||
|
mapInstance = null;
|
||||||
|
markerLayer = null;
|
||||||
|
currentFingerprint = "";
|
||||||
|
buildMap(el);
|
||||||
|
} else {
|
||||||
try { mapInstance.invalidateSize(); } catch (e) {}
|
try { mapInstance.invalidateSize(); } catch (e) {}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
renderMarkers(readMapData());
|
||||||
buildMap(el);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function wireViewToggle() {
|
function wireViewToggle() {
|
||||||
|
|
@ -72,9 +115,7 @@ function wireViewToggle() {
|
||||||
r.dataset.wired = "1";
|
r.dataset.wired = "1";
|
||||||
r.addEventListener("change", (e) => {
|
r.addEventListener("change", (e) => {
|
||||||
try { localStorage.setItem("lazyflat_view_mode", e.target.value); } catch (err) {}
|
try { localStorage.setItem("lazyflat_view_mode", e.target.value); } catch (err) {}
|
||||||
// Wait for CSS :has() to reflow, then build/size the map.
|
|
||||||
requestAnimationFrame(() => requestAnimationFrame(ensureMap));
|
requestAnimationFrame(() => requestAnimationFrame(ensureMap));
|
||||||
// belt & suspenders — re-check a couple more times in case of layout shifts.
|
|
||||||
setTimeout(ensureMap, 120);
|
setTimeout(ensureMap, 120);
|
||||||
setTimeout(ensureMap, 400);
|
setTimeout(ensureMap, 400);
|
||||||
});
|
});
|
||||||
|
|
@ -88,7 +129,6 @@ function restoreView() {
|
||||||
const el = document.querySelector(`input[name="view_mode"][value="${stored}"]`);
|
const el = document.querySelector(`input[name="view_mode"][value="${stored}"]`);
|
||||||
if (el && !el.checked) {
|
if (el && !el.checked) {
|
||||||
el.checked = true;
|
el.checked = true;
|
||||||
// Manually dispatching change would bubble and double-fire; call directly.
|
|
||||||
requestAnimationFrame(() => requestAnimationFrame(ensureMap));
|
requestAnimationFrame(() => requestAnimationFrame(ensureMap));
|
||||||
setTimeout(ensureMap, 120);
|
setTimeout(ensureMap, 120);
|
||||||
setTimeout(ensureMap, 400);
|
setTimeout(ensureMap, 400);
|
||||||
|
|
@ -98,7 +138,7 @@ function restoreView() {
|
||||||
function onReady() {
|
function onReady() {
|
||||||
wireViewToggle();
|
wireViewToggle();
|
||||||
restoreView();
|
restoreView();
|
||||||
ensureMap(); // handles the case where map view is already visible
|
ensureMap();
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", onReady);
|
document.addEventListener("DOMContentLoaded", onReady);
|
||||||
|
|
|
||||||
|
|
@ -96,12 +96,13 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Karte -->
|
<!-- Karte (Leaflet-Container bleibt über HTMX-Swaps hinweg erhalten) -->
|
||||||
<section class="view-map">
|
<section class="view-map">
|
||||||
<div class="card p-3">
|
<div class="card p-3">
|
||||||
<div id="flats-map" data-flats='{{ map_points | tojson }}'></div>
|
<div id="flats-map" hx-preserve="true"></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<script id="flats-map-data" type="application/json">{{ map_points | tojson }}</script>
|
||||||
|
|
||||||
<!-- Liste -->
|
<!-- Liste -->
|
||||||
<section class="view-list card">
|
<section class="view-list card">
|
||||||
|
|
@ -124,7 +125,6 @@
|
||||||
{% if f.rooms %}{{ "%.1f"|format(f.rooms) }} Z{% endif %}
|
{% if f.rooms %}{{ "%.1f"|format(f.rooms) }} Z{% endif %}
|
||||||
{% if f.size %} · {{ "%.0f"|format(f.size) }} m²{% endif %}
|
{% if f.size %} · {{ "%.0f"|format(f.size) }} m²{% endif %}
|
||||||
{% if f.total_rent %} · {{ "%.0f"|format(f.total_rent) }} €{% endif %}
|
{% if f.total_rent %} · {{ "%.0f"|format(f.total_rent) }} €{% endif %}
|
||||||
{% if f.sqm_price %} ({{ "%.2f"|format(f.sqm_price) }} €/m²){% endif %}
|
|
||||||
{% if f.connectivity_morning_time %} · {{ "%.0f"|format(f.connectivity_morning_time) }} min morgens{% endif %}
|
{% if f.connectivity_morning_time %} · {{ "%.0f"|format(f.connectivity_morning_time) }} min morgens{% endif %}
|
||||||
{% if f.wbs %} · WBS: {{ f.wbs }}{% endif %}
|
{% if f.wbs %} · WBS: {{ f.wbs }}{% endif %}
|
||||||
· entdeckt <span data-rel-utc="{{ f.discovered_at|iso_utc }}" title="{{ f.discovered_at|de_dt }}">…</span>
|
· entdeckt <span data-rel-utc="{{ f.discovered_at|iso_utc }}" title="{{ f.discovered_at|de_dt }}">…</span>
|
||||||
|
|
@ -170,4 +170,38 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{% if rejected_flats %}
|
||||||
|
<section class="card">
|
||||||
|
<details class="group">
|
||||||
|
<summary class="px-4 py-3 text-sm font-medium flex items-center justify-between cursor-pointer">
|
||||||
|
<span>Abgelehnte Wohnungen</span>
|
||||||
|
<span class="text-xs text-slate-500">{{ rejected_flats|length }}</span>
|
||||||
|
</summary>
|
||||||
|
<div class="divide-y divide-soft border-t border-soft">
|
||||||
|
{% for f in rejected_flats %}
|
||||||
|
<div class="px-4 py-3 flex flex-col md:flex-row md:items-center gap-3">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<a class="font-medium truncate" href="{{ f.link }}" target="_blank" rel="noopener noreferrer">
|
||||||
|
{{ f.address or f.link }}
|
||||||
|
</a>
|
||||||
|
<div class="text-xs text-slate-500 mt-0.5">
|
||||||
|
{% if f.rooms %}{{ "%.1f"|format(f.rooms) }} Z{% endif %}
|
||||||
|
{% if f.size %} · {{ "%.0f"|format(f.size) }} m²{% endif %}
|
||||||
|
{% if f.total_rent %} · {{ "%.0f"|format(f.total_rent) }} €{% endif %}
|
||||||
|
· abgelehnt <span data-rel-utc="{{ f.rejected_at|iso_utc }}" title="{{ f.rejected_at|de_dt }}">…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/actions/unreject"
|
||||||
|
hx-post="/actions/unreject" hx-target="#wohnungen-body" hx-swap="outerHTML">
|
||||||
|
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||||
|
<input type="hidden" name="flat_id" value="{{ f.id }}">
|
||||||
|
<button class="btn btn-ghost text-sm" type="submit">Wiederherstellen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue