lazyflat/web/static/app.js
EiSiMo 81d6b65eae feat(notifications): new match format with Gmaps + lazyflat deep-link
New Telegram match layout:
  Karl-Ziegler-Straße 7           (linked → Google Maps)
  12489 Treptow-Köpenick
  Miete: 944.12 (18.51 €/m²)
  Fläche: 51.0
  Zimmer: 2.0
  WBS: nicht erforderlich

  Zur original Anzeige            (→ flat URL)
  Zur lazyflat Seite              (→ /?flat=<id>)

Deep-link behavior on lazyflat: ?flat=<id> expands the matching row,
scrolls it into view, and pulses a yellow highlight for 3s. The query
param is stripped from history afterwards so reload stays clean.
Unknown flat IDs drop the param silently.

Helpers: _address_lines splits the scraper's "Street, PLZ, District"
into two display lines; _gmaps_url falls back to a maps.google query
when the payload has no explicit link; _wbs_label normalises the
German WBS variants to "erforderlich" / "nicht erforderlich".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:28:04 +02:00

130 lines
4.7 KiB
JavaScript

// lazyflat — live time helpers.
// [data-rel-utc="<iso>"] → "vor 3 min" (relative-past, updated every 5 s)
// [data-counter-up-utc="<iso>"] → "vor X s" counting up each second (used for
// "aktualisiert vor X s"; resets when the
// server ships a newer timestamp in the swap)
function fmtRelative(iso) {
const ts = Date.parse(iso);
if (!iso || Number.isNaN(ts)) return "—";
const diff = Math.max(0, Math.floor((Date.now() - ts) / 1000));
if (diff < 5) return "gerade eben";
if (diff < 60) return `vor ${diff} s`;
if (diff < 3600) return `vor ${Math.floor(diff / 60)} min`;
if (diff < 86400) return `vor ${Math.floor(diff / 3600)} h`;
return `vor ${Math.floor(diff / 86400)} Tagen`;
}
function fmtCountUp(iso) {
const ts = Date.parse(iso);
if (!iso || Number.isNaN(ts)) return "—";
const diff = Math.max(0, Math.floor((Date.now() - ts) / 1000));
return `vor ${diff} s`;
}
function updateRelativeTimes() {
document.querySelectorAll("[data-rel-utc]").forEach((el) => {
el.textContent = fmtRelative(el.dataset.relUtc);
});
}
function updateCounterUps() {
document.querySelectorAll("[data-counter-up-utc]").forEach((el) => {
el.textContent = fmtCountUp(el.dataset.counterUpUtc);
});
}
// After the HTMX swap rebuilds the list, the open-chevron class is gone even
// though the corresponding .flat-detail pane is preserved. Re-sync by looking
// at pane visibility.
function syncFlatExpandState() {
document.querySelectorAll(".flat-detail").forEach((pane) => {
const row = pane.closest(".flat-row");
if (!row) return;
const btn = row.querySelector(".flat-expand-btn");
if (!btn) return;
const open = pane.dataset.loaded === "1" && pane.style.display !== "none";
btn.classList.toggle("open", open);
});
}
function tick() {
updateRelativeTimes();
updateCounterUps();
}
document.addEventListener("DOMContentLoaded", tick);
document.addEventListener("DOMContentLoaded", syncFlatExpandState);
if (document.body) {
document.body.addEventListener("htmx:afterSwap", tick);
document.body.addEventListener("htmx:afterSwap", syncFlatExpandState);
}
setInterval(updateCounterUps, 1000);
setInterval(updateRelativeTimes, 5000);
// Flat detail expand — lazily fetches /partials/wohnung/<id> into the sibling
// .flat-detail container on first open, toggles visibility on subsequent clicks.
// Event delegation survives HTMX swaps without re-binding on each poll.
document.addEventListener("click", (ev) => {
const btn = ev.target.closest(".flat-expand-btn");
if (!btn) return;
const row = btn.closest(".flat-row");
if (!row) return;
const pane = row.querySelector(".flat-detail");
if (!pane) return;
if (btn.classList.contains("open")) {
pane.style.display = "none";
btn.classList.remove("open");
return;
}
btn.classList.add("open");
pane.style.display = "block";
if (pane.dataset.loaded) return;
pane.innerHTML = '<div class="px-4 py-5 text-sm text-slate-500">lädt…</div>';
const flatId = btn.dataset.flatId || "";
fetch("/partials/wohnung/" + encodeURIComponent(flatId),
{ headers: { "HX-Request": "true" } })
.then((r) => r.text())
.then((html) => {
pane.innerHTML = html;
pane.dataset.loaded = "1";
})
.catch(() => {
pane.innerHTML = '<div class="px-4 py-5 text-sm text-slate-500">Detail konnte nicht geladen werden.</div>';
});
});
// Deep-link landing: ?flat=<id> on first load expands + highlights that row.
// Used from Telegram match notifications so "Zur lazyflat Seite" jumps
// straight to the relevant card.
function openDeepLinkedFlat() {
const params = new URLSearchParams(location.search);
const targetId = params.get("flat");
if (!targetId) return;
let found = null;
for (const btn of document.querySelectorAll(".flat-expand-btn")) {
if (btn.dataset.flatId === targetId) { found = btn; break; }
}
if (!found) {
// Flat not in the visible list (filter/age/rejected). Drop the param so
// a reload doesn't keep failing silently.
params.delete("flat");
const qs = params.toString();
history.replaceState(null, "", location.pathname + (qs ? "?" + qs : "") + location.hash);
return;
}
const row = found.closest(".flat-row");
if (!found.classList.contains("open")) found.click();
if (row) {
row.scrollIntoView({ behavior: "smooth", block: "center" });
row.classList.add("flat-highlight");
setTimeout(() => row.classList.remove("flat-highlight"), 3000);
}
params.delete("flat");
const qs = params.toString();
history.replaceState(null, "", location.pathname + (qs ? "?" + qs : "") + location.hash);
}
document.addEventListener("DOMContentLoaded", openDeepLinkedFlat);