ui: live timers, Berlin timestamps, ZIP failure reports, drop kill-switch/Fehler tab

* remove the kill-switch: auto-apply toggle is the single on/off; manual
  'Bewerben' button now only gated by apply reachability; circuit breaker
  stays but only gates auto-apply (manual bypasses, so a user can retry)
* Berlin-timezone date filter (de_dt) formats timestamps as DD.MM.YYYY HH:MM
  everywhere; storage stays UTC
* Wohnungen: live 'entdeckt vor X' on every flat + 'nächste Aktualisierung in Xs'
  countdown in the header, driven by /static/app.js; HTMX polls body every 30s
* drop the Fehler tab entirely; failed applications now carry a
  'Fehler-Report herunterladen (ZIP)' link -> /bewerbungen/{id}/report.zip
  bundles application.json, flat.json, profile_snapshot.json, forensics.json,
  step_log.txt, page.html, console/errors/network JSONs, and decoded
  screenshots/*.jpg for AI-assisted debugging
* trim the 'sensibel' blurb from the Profil tab

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Moritz 2026-04-21 11:09:37 +02:00
parent c630b500ef
commit 332d9eea19
13 changed files with 429 additions and 343 deletions

View file

@ -1,27 +1,37 @@
""" """
lazyflat web app. lazyflat web app.
Five tabs: Four tabs:
- / Wohnungen (all flats, per-user match highlighting, filter block, auto-apply switch) - / Wohnungen (all flats, per-user match highlighting, filter block, auto-apply switch)
- /bewerbungen Bewerbungen (user's application history, forensics drill-in) - /bewerbungen Bewerbungen (history + forensics; failed apps expose a ZIP report download)
- /logs Logs (user-scoped audit log) - /logs Logs (user-scoped audit log)
- /fehler Fehler (user-scoped error records + admin-global)
- /einstellungen/<section> Einstellungen: profile, filter, notifications, account, admin users - /einstellungen/<section> Einstellungen: profile, filter, notifications, account, admin users
All state-changing POSTs require CSRF. Internal endpoints require INTERNAL_API_KEY. All state-changing POSTs require CSRF. Internal endpoints require INTERNAL_API_KEY.
""" """
import asyncio import asyncio
import base64
import hmac import hmac
import io
import json import json
import logging import logging
import sqlite3 import sqlite3
import threading import zipfile
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import Depends, FastAPI, Form, Header, HTTPException, Request, Response, status from fastapi import Depends, FastAPI, Form, Header, HTTPException, Request, Response, status
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
try:
from zoneinfo import ZoneInfo
BERLIN_TZ = ZoneInfo("Europe/Berlin")
except Exception:
BERLIN_TZ = timezone.utc
import db import db
import notifications import notifications
import retention import retention
@ -40,7 +50,12 @@ from auth import (
verify_login, verify_login,
) )
from matching import flat_matches_filter, row_to_dict from matching import flat_matches_filter, row_to_dict
from settings import APPLY_FAILURE_THRESHOLD, INTERNAL_API_KEY, PUBLIC_URL from settings import (
ALERT_SCRAPE_INTERVAL_SECONDS,
APPLY_FAILURE_THRESHOLD,
INTERNAL_API_KEY,
PUBLIC_URL,
)
logging.basicConfig( logging.basicConfig(
@ -55,11 +70,9 @@ apply_client = ApplyClient()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# App # App + Jinja
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
from contextlib import asynccontextmanager
@asynccontextmanager @asynccontextmanager
async def lifespan(_app: FastAPI): async def lifespan(_app: FastAPI):
db.init_db() db.init_db()
@ -74,6 +87,40 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
def _parse_iso(s: str | None) -> datetime | None:
if not s:
return None
try:
# Python handles +00:00 but not trailing Z. Storage always uses +00:00.
return datetime.fromisoformat(s)
except ValueError:
return None
def _de_dt(s: str | None) -> str:
"""UTC ISO → 'DD.MM.YYYY HH:MM' in Europe/Berlin."""
dt = _parse_iso(s)
if dt is None:
return ""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(BERLIN_TZ).strftime("%d.%m.%Y %H:%M")
def _iso_utc(s: str | None) -> str:
"""Pass-through of a UTC ISO string (for data-attr in JS)."""
dt = _parse_iso(s)
if dt is None:
return ""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc).isoformat(timespec="seconds")
templates.env.filters["de_dt"] = _de_dt
templates.env.filters["iso_utc"] = _iso_utc
@app.middleware("http") @app.middleware("http")
async def security_headers(request: Request, call_next): async def security_headers(request: Request, call_next):
resp: Response = await call_next(request) resp: Response = await call_next(request)
@ -117,17 +164,32 @@ def base_context(request: Request, user, active_tab: str) -> dict:
} }
def _get_apply_gate(user_id: int) -> tuple[bool, str]: def _manual_apply_allowed() -> tuple[bool, str]:
prefs = db.get_preferences(user_id) """Manual 'Bewerben' button is only blocked if the apply service is down."""
if prefs["kill_switch"]:
return False, "kill switch aktiv"
if prefs["apply_circuit_open"]:
return False, "circuit breaker offen (zu viele fehlgeschlagene Bewerbungen)"
if not apply_client.health(): if not apply_client.health():
return False, "apply-service nicht erreichbar" return False, "apply-service nicht erreichbar"
return True, "" return True, ""
def _auto_apply_allowed(prefs) -> bool:
"""Auto-apply trigger gate: user must have enabled it and the circuit must be closed."""
if not prefs["auto_apply_enabled"]:
return False
if prefs["apply_circuit_open"]:
return False
return apply_client.health()
def _next_scrape_utc() -> str:
hb = db.get_state("last_alert_heartbeat")
dt = _parse_iso(hb)
if dt is None:
return ""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return (dt + timedelta(seconds=ALERT_SCRAPE_INTERVAL_SECONDS)).astimezone(timezone.utc).isoformat(timespec="seconds")
def _run_apply_background(user_id: int, flat_id: str, url: str, triggered_by: str) -> None: def _run_apply_background(user_id: int, flat_id: str, url: str, triggered_by: str) -> None:
prefs = db.get_preferences(user_id) prefs = db.get_preferences(user_id)
profile_row = db.get_profile(user_id) profile_row = db.get_profile(user_id)
@ -153,7 +215,6 @@ def _run_apply_background(user_id: int, flat_id: str, url: str, triggered_by: st
db.finish_application(app_id, success=success, message=message, db.finish_application(app_id, success=success, message=message,
provider=provider, forensics=forensics) provider=provider, forensics=forensics)
# Circuit breaker (per user)
if success: if success:
db.update_preferences(user_id, {"apply_recent_failures": 0}) db.update_preferences(user_id, {"apply_recent_failures": 0})
else: else:
@ -165,13 +226,11 @@ def _run_apply_background(user_id: int, flat_id: str, url: str, triggered_by: st
summary=f"{failures} aufeinanderfolgende Fehler", summary=f"{failures} aufeinanderfolgende Fehler",
application_id=app_id) application_id=app_id)
db.update_preferences(user_id, updates) db.update_preferences(user_id, updates)
# record forensic error row
db.log_error(source="apply", kind="apply_failure", user_id=user_id, db.log_error(source="apply", kind="apply_failure", user_id=user_id,
summary=message or "Bewerbung fehlgeschlagen", summary=message or "Bewerbung fehlgeschlagen",
application_id=app_id, application_id=app_id,
context={"provider": provider, "url": url}) context={"provider": provider, "url": url})
# Notify user
flat = db.get_flat(flat_id) flat = db.get_flat(flat_id)
flat_dict = {"address": flat["address"] if flat else "", "link": url, flat_dict = {"address": flat["address"] if flat else "", "link": url,
"rooms": flat["rooms"] if flat else None, "rooms": flat["rooms"] if flat else None,
@ -246,17 +305,6 @@ def logout(request: Request):
# Tab: Wohnungen # Tab: Wohnungen
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@app.get("/", response_class=HTMLResponse)
def tab_wohnungen(request: Request):
u = current_user(request)
if not u:
return RedirectResponse("/login", status_code=303)
ctx = base_context(request, u, "wohnungen")
ctx.update(_wohnungen_context(u))
return templates.TemplateResponse("wohnungen.html", ctx)
def _wohnungen_context(user) -> dict: def _wohnungen_context(user) -> dict:
uid = user["id"] uid = user["id"]
filters_row = db.get_filters(uid) filters_row = db.get_filters(uid)
@ -266,14 +314,9 @@ def _wohnungen_context(user) -> dict:
flats_view = [] flats_view = []
for f in flats: for f in flats:
try:
payload = json.loads(f["payload_json"])
except Exception:
payload = {}
last = db.last_application_for_flat(uid, f["id"]) last = db.last_application_for_flat(uid, f["id"])
flats_view.append({ flats_view.append({
"row": f, "row": f,
"payload": payload,
"matched": flat_matches_filter({ "matched": flat_matches_filter({
"rooms": f["rooms"], "total_rent": f["total_rent"], "size": f["size"], "rooms": f["rooms"], "total_rent": f["total_rent"], "size": f["size"],
"wbs": f["wbs"], "connectivity": {"morning_time": f["connectivity_morning_time"]}, "wbs": f["wbs"], "connectivity": {"morning_time": f["connectivity_morning_time"]},
@ -281,22 +324,40 @@ def _wohnungen_context(user) -> dict:
"last": last, "last": last,
}) })
allowed, reason = _get_apply_gate(uid) allowed, reason = _manual_apply_allowed()
return { return {
"flats": flats_view, "flats": flats_view,
"filters": filters, "filters": filters,
"auto_apply_enabled": bool(prefs["auto_apply_enabled"]), "auto_apply_enabled": bool(prefs["auto_apply_enabled"]),
"submit_forms": bool(prefs["submit_forms"]), "submit_forms": bool(prefs["submit_forms"]),
"kill_switch": bool(prefs["kill_switch"]),
"circuit_open": bool(prefs["apply_circuit_open"]), "circuit_open": bool(prefs["apply_circuit_open"]),
"apply_failures": int(prefs["apply_recent_failures"] or 0), "apply_failures": int(prefs["apply_recent_failures"] or 0),
"apply_allowed": allowed, "apply_allowed": allowed,
"apply_block_reason": reason, "apply_block_reason": reason,
"apply_reachable": apply_client.health(), "apply_reachable": apply_client.health(),
"last_alert_heartbeat": db.get_state("last_alert_heartbeat") or "", "last_alert_heartbeat": db.get_state("last_alert_heartbeat") or "",
"next_scrape_utc": _next_scrape_utc(),
"scrape_interval_seconds": ALERT_SCRAPE_INTERVAL_SECONDS,
} }
@app.get("/", response_class=HTMLResponse)
def tab_wohnungen(request: Request):
u = current_user(request)
if not u:
return RedirectResponse("/login", status_code=303)
ctx = base_context(request, u, "wohnungen")
ctx.update(_wohnungen_context(u))
return templates.TemplateResponse("wohnungen.html", ctx)
@app.get("/partials/wohnungen", response_class=HTMLResponse)
def partial_wohnungen(request: Request, user=Depends(require_user)):
ctx = base_context(request, user, "wohnungen")
ctx.update(_wohnungen_context(user))
return templates.TemplateResponse("_wohnungen_body.html", ctx)
@app.post("/actions/filters") @app.post("/actions/filters")
async def action_save_filters( async def action_save_filters(
request: Request, request: Request,
@ -342,21 +403,6 @@ async def action_auto_apply(
return RedirectResponse("/", status_code=303) return RedirectResponse("/", status_code=303)
@app.post("/actions/kill-switch")
async def action_kill_switch(
request: Request,
value: str = Form(...),
csrf: str = Form(...),
user=Depends(require_user),
):
require_csrf(user["id"], csrf)
new = 1 if value == "on" else 0
db.update_preferences(user["id"], {"kill_switch": new})
db.log_audit(user["username"], "kill_switch", "on" if new else "off",
user_id=user["id"], ip=client_ip(request))
return RedirectResponse("/", status_code=303)
@app.post("/actions/reset-circuit") @app.post("/actions/reset-circuit")
async def action_reset_circuit( async def action_reset_circuit(
request: Request, request: Request,
@ -377,7 +423,7 @@ async def action_apply(
user=Depends(require_user), user=Depends(require_user),
): ):
require_csrf(user["id"], csrf) require_csrf(user["id"], csrf)
allowed, reason = _get_apply_gate(user["id"]) allowed, reason = _manual_apply_allowed()
if not allowed: if not allowed:
raise HTTPException(409, f"apply disabled: {reason}") raise HTTPException(409, f"apply disabled: {reason}")
flat = db.get_flat(flat_id) flat = db.get_flat(flat_id)
@ -418,6 +464,79 @@ def bewerbung_detail(request: Request, app_id: int):
return templates.TemplateResponse("bewerbung_detail.html", ctx) return templates.TemplateResponse("bewerbung_detail.html", ctx)
@app.get("/bewerbungen/{app_id}/report.zip")
def bewerbung_zip(request: Request, app_id: int):
u = current_user(request)
if not u:
raise HTTPException(401)
a = db.get_application(app_id)
if not a or (a["user_id"] != u["id"] and not u["is_admin"]):
raise HTTPException(404)
flat = db.get_flat(a["flat_id"])
forensics = json.loads(a["forensics_json"]) if a["forensics_json"] else {}
profile = json.loads(a["profile_snapshot_json"]) if a["profile_snapshot_json"] else {}
app_meta = {k: a[k] for k in a.keys() if k not in ("forensics_json", "profile_snapshot_json")}
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr(
"README.txt",
f"lazyflat application report\n"
f"application_id={a['id']}\n"
f"flat_id={a['flat_id']}\n"
f"provider={a['provider']}\n"
f"success={a['success']}\n"
f"started_at_utc={a['started_at']}\n"
f"finished_at_utc={a['finished_at']}\n"
f"submit_forms_used={bool(a['submit_forms_used'])}\n"
f"\n"
f"Contents:\n"
f" application.json DB row + metadata\n"
f" flat.json Flat details at discovery time\n"
f" profile_snapshot.json Profile used for this attempt\n"
f" forensics.json Full captured forensics\n"
f" step_log.txt Human-readable step log\n"
f" page.html Final page HTML (if captured)\n"
f" console.json Browser console entries\n"
f" errors.json Browser pageerror events\n"
f" network.json Network requests + partial responses\n"
f" screenshots/*.jpg Screenshots at key moments\n"
)
zf.writestr("application.json", json.dumps(app_meta, indent=2, default=str))
zf.writestr("flat.json", json.dumps(dict(flat) if flat else {}, indent=2, default=str))
zf.writestr("profile_snapshot.json", json.dumps(profile, indent=2, default=str))
zf.writestr("forensics.json", json.dumps(forensics, indent=2, default=str))
step_lines = []
for s in forensics.get("steps", []):
step_lines.append(f"[{s.get('ts', 0):7.2f}s] {s.get('step', '?'):<24} {s.get('status', ''):<5} {s.get('detail', '')}")
zf.writestr("step_log.txt", "\n".join(step_lines))
if forensics.get("final_html"):
zf.writestr("page.html", forensics["final_html"])
zf.writestr("console.json", json.dumps(forensics.get("console", []), indent=2))
zf.writestr("errors.json", json.dumps(forensics.get("errors", []), indent=2))
zf.writestr("network.json", json.dumps(forensics.get("network", []), indent=2))
for idx, s in enumerate(forensics.get("screenshots", []), start=1):
b64 = s.get("b64_jpeg", "")
if b64:
try:
data = base64.b64decode(b64)
label = (s.get("label") or f"step{idx}").replace("/", "_").replace(" ", "_")
zf.writestr(f"screenshots/{idx:02d}_{label}.jpg", data)
except Exception:
pass
buf.seek(0)
filename = f"lazyflat-report-{a['id']}.zip"
return StreamingResponse(
buf, media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Tab: Logs # Tab: Logs
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -432,41 +551,6 @@ def tab_logs(request: Request):
return templates.TemplateResponse("logs.html", ctx) return templates.TemplateResponse("logs.html", ctx)
# ---------------------------------------------------------------------------
# Tab: Fehler
# ---------------------------------------------------------------------------
@app.get("/fehler", response_class=HTMLResponse)
def tab_fehler(request: Request):
u = current_user(request)
if not u:
return RedirectResponse("/login", status_code=303)
ctx = base_context(request, u, "fehler")
ctx["errors"] = db.recent_errors(u["id"], limit=200, include_global=bool(u["is_admin"]))
return templates.TemplateResponse("fehler.html", ctx)
@app.get("/fehler/{err_id}", response_class=HTMLResponse)
def fehler_detail(request: Request, err_id: int):
u = current_user(request)
if not u:
return RedirectResponse("/login", status_code=303)
e = db.get_error(err_id)
if not e or (e["user_id"] is not None and e["user_id"] != u["id"] and not u["is_admin"]):
raise HTTPException(404, "not found")
app_row = db.get_application(e["application_id"]) if e["application_id"] else None
forensics = None
if app_row and app_row["forensics_json"]:
try:
forensics = json.loads(app_row["forensics_json"])
except Exception:
forensics = None
context = json.loads(e["context_json"]) if e["context_json"] else None
ctx = base_context(request, u, "fehler")
ctx.update({"error": e, "application": app_row, "forensics": forensics, "context": context})
return templates.TemplateResponse("fehler_detail.html", ctx)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Tab: Einstellungen (sub-tabs) # Tab: Einstellungen (sub-tabs)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -595,8 +679,6 @@ async def action_submit_forms(
return RedirectResponse("/einstellungen/profil", status_code=303) return RedirectResponse("/einstellungen/profil", status_code=303)
# --- Admin: Benutzer ---------------------------------------------------------
@app.post("/actions/users/create") @app.post("/actions/users/create")
async def action_users_create( async def action_users_create(
request: Request, request: Request,
@ -654,7 +736,6 @@ async def internal_submit_flat(
if not is_new: if not is_new:
return {"status": "duplicate"} return {"status": "duplicate"}
# per-user matching + auto-apply + notifications
for u in db.list_users(): for u in db.list_users():
if u["disabled"]: if u["disabled"]:
continue continue
@ -668,7 +749,7 @@ async def internal_submit_flat(
notifications.on_match(u["id"], payload) notifications.on_match(u["id"], payload)
prefs = db.get_preferences(u["id"]) prefs = db.get_preferences(u["id"])
if prefs["auto_apply_enabled"] and not prefs["kill_switch"] and not prefs["apply_circuit_open"]: if _auto_apply_allowed(prefs):
_kick_apply(u["id"], str(payload["id"]), payload["link"], "auto") _kick_apply(u["id"], str(payload["id"]), payload["link"], "auto")
db.log_audit("system", "auto_apply_kick", db.log_audit("system", "auto_apply_kick",
f"user={u['username']} flat={payload['id']}", f"user={u['username']} flat={payload['id']}",
@ -689,7 +770,6 @@ async def internal_report_error(
payload: dict, payload: dict,
_g: None = Depends(require_internal), _g: None = Depends(require_internal),
): ):
"""Alert/other services can push errors here."""
db.log_error( db.log_error(
source=payload.get("source", "unknown"), source=payload.get("source", "unknown"),
kind=payload.get("kind", "error"), kind=payload.get("kind", "error"),

View file

@ -37,6 +37,9 @@ APPLY_URL: str = getenv("APPLY_URL", "http://apply:8000")
APPLY_TIMEOUT: int = int(getenv("APPLY_TIMEOUT", "600")) APPLY_TIMEOUT: int = int(getenv("APPLY_TIMEOUT", "600"))
APPLY_FAILURE_THRESHOLD: int = int(getenv("APPLY_FAILURE_THRESHOLD", "3")) APPLY_FAILURE_THRESHOLD: int = int(getenv("APPLY_FAILURE_THRESHOLD", "3"))
# --- Alert service knob (mirrored so web can predict the next scrape) ---------
ALERT_SCRAPE_INTERVAL_SECONDS: int = int(getenv("ALERT_SCRAPE_INTERVAL_SECONDS", getenv("SLEEP_INTERVALL", "60")))
# --- Storage ------------------------------------------------------------------ # --- Storage ------------------------------------------------------------------
DATA_DIR: Path = Path(getenv("DATA_DIR", "/data")) DATA_DIR: Path = Path(getenv("DATA_DIR", "/data"))
DATA_DIR.mkdir(parents=True, exist_ok=True) DATA_DIR.mkdir(parents=True, exist_ok=True)

49
web/static/app.js Normal file
View file

@ -0,0 +1,49 @@
// lazyflat — live time helpers.
// Any element with [data-rel-utc="<iso>"] gets its text replaced every 5s
// with a German relative-time string ("vor 3 min"). Elements with
// [data-countdown-utc="<iso>"] show "in Xs" counting down each second.
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 fmtCountdown(iso) {
const ts = Date.parse(iso);
if (!iso || Number.isNaN(ts)) return "—";
const secs = Math.floor((ts - Date.now()) / 1000);
if (secs <= 0) return "Aktualisierung läuft…";
if (secs < 60) return `in ${secs} s`;
if (secs < 3600) return `in ${Math.floor(secs / 60)} min`;
return `in ${Math.floor(secs / 3600)} h`;
}
function updateRelativeTimes() {
document.querySelectorAll("[data-rel-utc]").forEach((el) => {
el.textContent = fmtRelative(el.dataset.relUtc);
});
}
function updateCountdowns() {
document.querySelectorAll("[data-countdown-utc]").forEach((el) => {
el.textContent = fmtCountdown(el.dataset.countdownUtc);
});
}
function tick() {
updateRelativeTimes();
updateCountdowns();
}
// Run immediately + on intervals. Also re-run after HTMX swaps so freshly
// injected DOM gets formatted too.
document.addEventListener("DOMContentLoaded", tick);
document.body && document.body.addEventListener("htmx:afterSwap", tick);
setInterval(updateCountdowns, 1000);
setInterval(updateRelativeTimes, 5000);

View file

@ -21,7 +21,6 @@
<a class="tab {% if active_tab=='wohnungen' %}active{% endif %}" href="/">Wohnungen</a> <a class="tab {% if active_tab=='wohnungen' %}active{% endif %}" href="/">Wohnungen</a>
<a class="tab {% if active_tab=='bewerbungen' %}active{% endif %}" href="/bewerbungen">Bewerbungen</a> <a class="tab {% if active_tab=='bewerbungen' %}active{% endif %}" href="/bewerbungen">Bewerbungen</a>
<a class="tab {% if active_tab=='logs' %}active{% endif %}" href="/logs">Logs</a> <a class="tab {% if active_tab=='logs' %}active{% endif %}" href="/logs">Logs</a>
<a class="tab {% if active_tab=='fehler' %}active{% endif %}" href="/fehler">Fehler</a>
<a class="tab {% if active_tab=='einstellungen' %}active{% endif %}" href="/einstellungen">Einstellungen</a> <a class="tab {% if active_tab=='einstellungen' %}active{% endif %}" href="/einstellungen">Einstellungen</a>
</nav> </nav>
</header> </header>

View file

@ -1,8 +1,4 @@
<h2 class="font-semibold mb-4">Bewerbungsdaten</h2> <h2 class="font-semibold mb-4">Bewerbungsdaten</h2>
<p class="text-sm text-slate-600 mb-4">
Diese Angaben werden beim Bewerben an die jeweilige Website gesendet.
<span class="chip chip-warn">sensibel</span> — werden nur in der DB gespeichert und pro Bewerbung als Snapshot protokolliert.
</p>
<form method="post" action="/actions/profile" class="grid grid-cols-1 md:grid-cols-2 gap-4"> <form method="post" action="/actions/profile" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<input type="hidden" name="csrf" value="{{ csrf }}"> <input type="hidden" name="csrf" value="{{ csrf }}">

View file

@ -0,0 +1,178 @@
{# Renders the full Wohnungen body; used both by the full page and by HTMX poll. #}
<div id="wohnungen-body" class="space-y-6">
<!-- Auto-Bewerben + Status-Leiste -->
<section class="card p-5 flex flex-col md:flex-row md:items-center gap-4 justify-between">
<div class="flex flex-col gap-1">
<div class="text-xs uppercase tracking-wide text-slate-500">Auto-Bewerben</div>
<div class="flex items-center gap-2">
{% if auto_apply_enabled %}
<span class="chip chip-warn">aktiviert</span>
<span class="text-sm text-slate-600">bei Match wird automatisch beworben</span>
{% else %}
<span class="chip chip-info">deaktiviert</span>
<span class="text-sm text-slate-600">Matches werden nur angezeigt</span>
{% endif %}
</div>
</div>
<div class="flex flex-wrap items-center gap-3">
<form method="post" action="/actions/auto-apply">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="value" value="{% if auto_apply_enabled %}off{% else %}on{% endif %}">
<button class="btn btn-hot {% if not auto_apply_enabled %}off{% endif %}"
onclick="return confirm('{% if auto_apply_enabled %}Auto-Bewerben deaktivieren?{% else %}Auto-Bewerben aktivieren? Das System bewirbt dann automatisch bei jedem Match bitte Profil und Filter prüfen.{% endif %}');"
type="submit">
{% if auto_apply_enabled %}AUTO-BEWERBEN DEAKTIVIEREN{% else %}AUTO-BEWERBEN AKTIVIEREN{% endif %}
</button>
</form>
{% if circuit_open %}
<form method="post" action="/actions/reset-circuit">
<input type="hidden" name="csrf" value="{{ csrf }}">
<button class="btn btn-ghost text-sm" type="submit">Circuit zurücksetzen</button>
</form>
{% endif %}
</div>
</section>
{% if not apply_allowed %}
<div class="card p-4">
<span class="chip chip-bad">apply blockiert</span>
<span class="ml-2 text-sm text-slate-600">{{ apply_block_reason }}</span>
</div>
{% endif %}
<!-- Status-Reihe -->
<section class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="card p-3">
<div class="text-xs text-slate-500">alert</div>
<div class="mt-1">
{% if last_alert_heartbeat %}<span class="chip chip-ok">live</span>
{% else %}<span class="chip chip-warn">kein Heartbeat</span>{% endif %}
</div>
</div>
<div class="card p-3">
<div class="text-xs text-slate-500">apply</div>
<div class="mt-1">
{% if apply_reachable %}<span class="chip chip-ok">ok</span>
{% else %}<span class="chip chip-bad">down</span>{% endif %}
</div>
</div>
<div class="card p-3">
<div class="text-xs text-slate-500">submit_forms</div>
<div class="mt-1">
{% if submit_forms %}<span class="chip chip-warn">echt senden</span>
{% else %}<span class="chip chip-info">dry-run</span>{% endif %}
</div>
</div>
<div class="card p-3">
<div class="text-xs text-slate-500">Fehler in Serie</div>
<div class="mt-1">
{% if circuit_open %}<span class="chip chip-bad">circuit open</span>
{% elif apply_failures > 0 %}<span class="chip chip-warn">{{ apply_failures }}</span>
{% else %}<span class="chip chip-ok">0</span>{% endif %}
</div>
</div>
</section>
<!-- Filter Panel -->
<details class="card" {% if not filters.rooms_min and not filters.max_rent %}open{% endif %}>
<summary class="px-5 py-3 font-semibold select-none">Eigene Filter</summary>
<form method="post" action="/actions/filters" class="p-5 grid grid-cols-2 md:grid-cols-3 gap-4">
<input type="hidden" name="csrf" value="{{ csrf }}">
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">Zimmer min</label>
<input class="input" name="rooms_min" value="{{ filters.rooms_min if filters.rooms_min is not none else '' }}" placeholder="z.B. 2">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">Zimmer max</label>
<input class="input" name="rooms_max" value="{{ filters.rooms_max if filters.rooms_max is not none else '' }}" placeholder="z.B. 3">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">max Miete (€)</label>
<input class="input" name="max_rent" value="{{ filters.max_rent if filters.max_rent is not none else '' }}" placeholder="1500">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">min Größe (m²)</label>
<input class="input" name="min_size" value="{{ filters.min_size if filters.min_size is not none else '' }}" placeholder="40">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">max Anfahrt morgens (min)</label>
<input class="input" name="max_morning_commute" value="{{ filters.max_morning_commute if filters.max_morning_commute is not none else '' }}" placeholder="50">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">WBS benötigt</label>
<select class="input" name="wbs_required">
<option value="" {% if not filters.wbs_required %}selected{% endif %}>egal</option>
<option value="yes" {% if filters.wbs_required == 'yes' %}selected{% endif %}>ja</option>
<option value="no" {% if filters.wbs_required == 'no' %}selected{% endif %}>nein</option>
</select>
</div>
<div class="col-span-2 md:col-span-3 flex gap-2 pt-2">
<button class="btn btn-primary" type="submit">Filter speichern</button>
<span class="text-xs text-slate-500 self-center">Leer lassen = kein Limit. Filter bestimmen Match-Hervorhebung + Auto-Bewerben.</span>
</div>
</form>
</details>
<!-- Liste aller Wohnungen -->
<section class="card">
<div class="flex items-center justify-between px-4 py-3 border-b border-soft gap-4 flex-wrap">
<h2 class="font-semibold">Neueste Wohnungen auf inberlinwohnen.de</h2>
<div class="text-xs text-slate-500 flex gap-3 items-center">
<span>{{ flats|length }} gesehen</span>
{% if next_scrape_utc %}
<span>· nächste Aktualisierung <span data-countdown-utc="{{ next_scrape_utc }}"></span></span>
{% else %}
<span>· warte auf alert…</span>
{% endif %}
</div>
</div>
<div class="divide-y divide-soft">
{% for item in flats %}
{% set f = item.row %}
<div class="px-4 py-3 flex flex-col md:flex-row md:items-center gap-3 {% if item.matched %}bg-[#f2f8ff]{% endif %}">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<a class="font-medium truncate" href="{{ f.link }}" target="_blank" rel="noopener noreferrer">
{{ f.address or f.link }}
</a>
{% if item.matched %}<span class="chip chip-ok">match</span>{% endif %}
{% if item.last and item.last.success == 1 %}<span class="chip chip-ok">beworben</span>
{% elif item.last and item.last.success == 0 %}<span class="chip chip-bad">apply fehlgeschlagen</span>
{% elif item.last %}<span class="chip chip-warn">läuft</span>{% endif %}
</div>
<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 %}
{% 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.wbs %} · WBS: {{ f.wbs }}{% endif %}
· entdeckt <span data-rel-utc="{{ f.discovered_at|iso_utc }}" title="{{ f.discovered_at|de_dt }}"></span>
</div>
{% if item.last and item.last.message %}
<div class="text-xs text-slate-500 mt-1 truncate">↳ {{ item.last.message }}</div>
{% endif %}
</div>
<div class="flex gap-2">
{% if apply_allowed and not (item.last and item.last.success == 1) %}
<form method="post" action="/actions/apply">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="flat_id" value="{{ f.id }}">
<button class="btn btn-primary text-sm" type="submit"
onclick="return confirm('Bewerbung für {{ (f.address or f.link)|e }} ausführen?');">
Bewerben
</button>
</form>
{% endif %}
</div>
</div>
{% else %}
<div class="px-4 py-8 text-center text-slate-500">Noch keine Wohnungen entdeckt.</div>
{% endfor %}
</div>
</section>
</div>

View file

@ -7,6 +7,7 @@
<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>
<script src="/static/app.js" defer></script>
<style> <style>
:root { :root {
--bg-from: #e4f0fb; --bg-to: #f7fbfe; --bg-from: #e4f0fb; --bg-to: #f7fbfe;

View file

@ -13,11 +13,18 @@
{% if application.provider %}<span class="chip chip-info">{{ application.provider }}</span>{% endif %} {% if application.provider %}<span class="chip chip-info">{{ application.provider }}</span>{% endif %}
{% if application.submit_forms_used %}<span class="chip chip-warn">echt gesendet</span> {% if application.submit_forms_used %}<span class="chip chip-warn">echt gesendet</span>
{% else %}<span class="chip chip-info">dry-run</span>{% endif %} {% else %}<span class="chip chip-info">dry-run</span>{% endif %}
{% if application.success == 0 %}
<a class="btn btn-danger text-sm ml-auto"
href="/bewerbungen/{{ application.id }}/report.zip">
Fehler-Report herunterladen (ZIP)
</a>
{% endif %}
</div> </div>
<div class="text-sm text-slate-600 space-y-1"> <div class="text-sm text-slate-600 space-y-1">
<div><span class="text-slate-500">URL:</span> <a href="{{ application.url }}" target="_blank" rel="noopener">{{ application.url }}</a></div> <div><span class="text-slate-500">URL:</span> <a href="{{ application.url }}" target="_blank" rel="noopener">{{ application.url }}</a></div>
<div><span class="text-slate-500">gestartet:</span> {{ application.started_at }}</div> <div><span class="text-slate-500">gestartet:</span> {{ application.started_at|de_dt }}</div>
<div><span class="text-slate-500">beendet:</span> {{ application.finished_at or "—" }}</div> <div><span class="text-slate-500">beendet:</span> {{ application.finished_at|de_dt if application.finished_at else "—" }}</div>
{% if application.message %}<div><span class="text-slate-500">Meldung:</span> {{ application.message }}</div>{% endif %} {% if application.message %}<div><span class="text-slate-500">Meldung:</span> {{ application.message }}</div>{% endif %}
</div> </div>
</section> </section>
@ -31,7 +38,7 @@
{% if forensics %} {% if forensics %}
<section class="card p-5 space-y-4"> <section class="card p-5 space-y-4">
<h3 class="font-semibold">Forensik (für KI-Debug)</h3> <h3 class="font-semibold">Forensik</h3>
<details open> <details open>
<summary class="font-medium">Step-Log ({{ forensics.steps|length }} Einträge, {{ forensics.duration_s }} s)</summary> <summary class="font-medium">Step-Log ({{ forensics.steps|length }} Einträge, {{ forensics.duration_s }} s)</summary>

View file

@ -11,18 +11,24 @@
<div class="px-4 py-3 text-sm"> <div class="px-4 py-3 text-sm">
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
{% if a.success == 1 %}<span class="chip chip-ok">ok</span> {% if a.success == 1 %}<span class="chip chip-ok">ok</span>
{% elif a.success == 0 %}<span class="chip chip-bad">fail</span> {% elif a.success == 0 %}<span class="chip chip-bad">fehlgeschlagen</span>
{% else %}<span class="chip chip-warn">läuft</span>{% endif %} {% else %}<span class="chip chip-warn">läuft</span>{% endif %}
<span class="chip chip-info">{{ a.triggered_by }}</span> <span class="chip chip-info">{{ a.triggered_by }}</span>
{% if a.provider %}<span class="chip chip-info">{{ a.provider }}</span>{% endif %} {% if a.provider %}<span class="chip chip-info">{{ a.provider }}</span>{% endif %}
{% if a.submit_forms_used %}<span class="chip chip-warn">echt gesendet</span> {% if a.submit_forms_used %}<span class="chip chip-warn">echt gesendet</span>
{% else %}<span class="chip chip-info">dry-run</span>{% endif %} {% else %}<span class="chip chip-info">dry-run</span>{% endif %}
<span class="text-slate-500 text-xs ml-auto">{{ a.started_at }}</span> <span class="text-slate-500 text-xs ml-auto"
title="{{ a.started_at|de_dt }}">{{ a.started_at|de_dt }}</span>
</div> </div>
<div class="mt-1 truncate"> <div class="mt-1 truncate">
<a href="/bewerbungen/{{ a.id }}">#{{ a.id }} — {{ a.address or a.url }}</a> <a href="/bewerbungen/{{ a.id }}">#{{ a.id }} — {{ a.address or a.url }}</a>
</div> </div>
{% if a.message %}<div class="text-xs text-slate-500 mt-0.5 truncate">{{ a.message }}</div>{% endif %} {% if a.message %}<div class="text-xs text-slate-500 mt-0.5 truncate">{{ a.message }}</div>{% endif %}
{% if a.success == 0 %}
<div class="mt-1">
<a class="text-xs" href="/bewerbungen/{{ a.id }}/report.zip">↓ Fehler-Report (ZIP)</a>
</div>
{% endif %}
</div> </div>
{% else %} {% else %}
<div class="px-4 py-8 text-center text-slate-500">Noch keine Bewerbungen.</div> <div class="px-4 py-8 text-center text-slate-500">Noch keine Bewerbungen.</div>

View file

@ -1,29 +0,0 @@
{% extends "_layout.html" %}
{% block title %}Fehler — lazyflat{% endblock %}
{% block content %}
<section class="card">
<div class="px-4 py-3 border-b border-soft flex items-center justify-between">
<h2 class="font-semibold">Fehler {% if is_admin %}<span class="chip chip-info ml-2">inkl. globaler</span>{% endif %}</h2>
<span class="text-xs text-slate-500">{{ errors|length }}</span>
</div>
<div class="divide-y divide-soft">
{% for e in errors %}
<a class="block px-4 py-3 hover:bg-[#f6fafd]" href="/fehler/{{ e.id }}">
<div class="flex items-center gap-2 flex-wrap">
<span class="chip chip-bad">{{ e.kind }}</span>
<span class="chip chip-info">{{ e.source }}</span>
{% if e.application_id %}<span class="chip chip-info">#{{ e.application_id }}</span>{% endif %}
<span class="text-xs text-slate-500 ml-auto">{{ e.timestamp }}</span>
</div>
<div class="text-sm mt-1 truncate">{{ e.summary or "(kein Text)" }}</div>
</a>
{% else %}
<div class="px-4 py-8 text-center text-slate-500">Keine Fehler — läuft rund.</div>
{% endfor %}
</div>
<div class="px-4 py-3 border-t border-soft text-xs text-slate-500">
Fehler werden 14 Tage aufbewahrt. Bei fehlgeschlagenen Bewerbungen enthält die Detailseite Screenshots,
Step-Log, Browser-Konsole + Netzwerk-Trace für KI-gestützte Fehleranalyse.
</div>
</section>
{% endblock %}

View file

@ -1,32 +0,0 @@
{% extends "_layout.html" %}
{% block title %}Fehler #{{ error.id }} — lazyflat{% endblock %}
{% block content %}
<a href="/fehler" class="text-sm">← zurück zu den Fehlern</a>
<section class="card p-5 space-y-2">
<div class="flex items-center gap-2 flex-wrap">
<h2 class="font-semibold text-lg">Fehler #{{ error.id }}</h2>
<span class="chip chip-bad">{{ error.kind }}</span>
<span class="chip chip-info">{{ error.source }}</span>
</div>
<div class="text-sm text-slate-600">
{{ error.timestamp }} · {{ error.summary or "(kein Text)" }}
</div>
{% if context %}
<details class="mt-2">
<summary class="font-medium">Kontext</summary>
<pre class="mono whitespace-pre-wrap break-all mt-2 p-3 bg-[#f6fafd] rounded-lg border border-soft">{{ context | tojson(indent=2) }}</pre>
</details>
{% endif %}
</section>
{% if application %}
<section class="card p-5">
<h3 class="font-semibold mb-2">Zugehörige Bewerbung</h3>
<div class="text-sm">
<a href="/bewerbungen/{{ application.id }}">Bewerbung #{{ application.id }} öffnen</a>
— vollständige Forensik dort.
</div>
</section>
{% endif %}
{% endblock %}

View file

@ -6,7 +6,7 @@
<div class="divide-y divide-soft"> <div class="divide-y divide-soft">
{% for e in events %} {% for e in events %}
<div class="px-4 py-2 mono"> <div class="px-4 py-2 mono">
<span class="text-slate-500">{{ e.timestamp }}</span> <span class="text-slate-500">{{ e.timestamp|de_dt }}</span>
<span class="text-slate-400">{{ e.actor }}</span> <span class="text-slate-400">{{ e.actor }}</span>
<span class="text-slate-700">{{ e.action }}</span> <span class="text-slate-700">{{ e.action }}</span>
{% if e.details %}<span class="text-slate-500">— {{ e.details }}</span>{% endif %} {% if e.details %}<span class="text-slate-500">— {{ e.details }}</span>{% endif %}

View file

@ -1,182 +1,10 @@
{% extends "_layout.html" %} {% extends "_layout.html" %}
{% block title %}Wohnungen — lazyflat{% endblock %} {% block title %}Wohnungen — lazyflat{% endblock %}
{% block content %} {% block content %}
<div id="wohnungen-body"
<!-- Auto-Bewerben + Status-Leiste --> hx-get="/partials/wohnungen"
<section class="card p-5 flex flex-col md:flex-row md:items-center gap-4 justify-between"> hx-trigger="every 30s"
<div class="flex flex-col gap-1"> hx-swap="outerHTML">
<div class="text-xs uppercase tracking-wide text-slate-500">Auto-Bewerben</div> {% include "_wohnungen_body.html" %}
<div class="flex items-center gap-2">
{% if auto_apply_enabled %}
<span class="chip chip-warn">an</span>
<span class="text-sm text-slate-600">bei Match wird automatisch beworben</span>
{% else %}
<span class="chip chip-info">aus</span>
<span class="text-sm text-slate-600">Matches werden nur angezeigt</span>
{% endif %}
</div>
</div>
<div class="flex flex-wrap items-center gap-3">
<!-- der rote Knopf -->
<form method="post" action="/actions/auto-apply">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="value" value="{% if auto_apply_enabled %}off{% else %}on{% endif %}">
<button class="btn btn-hot {% if not auto_apply_enabled %}off{% endif %}"
onclick="return confirm('{% if auto_apply_enabled %}Auto-Bewerben deaktivieren?{% else %}Auto-Bewerben aktivieren? Das System bewirbt dann automatisch bei jedem Match bitte Profil und Filter prüfen.{% endif %}');"
type="submit">
{% if auto_apply_enabled %}AUTO-BEWERBEN: AN{% else %}AUTO-BEWERBEN AKTIVIEREN{% endif %}
</button>
</form>
<!-- kill switch -->
<form method="post" action="/actions/kill-switch">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="value" value="{% if kill_switch %}off{% else %}on{% endif %}">
<button class="btn {% if kill_switch %}btn-ghost{% else %}btn-danger{% endif %} text-sm" type="submit">
{% if kill_switch %}Kill-Switch deaktivieren{% else %}Kill-Switch{% endif %}
</button>
</form>
{% if circuit_open %}
<form method="post" action="/actions/reset-circuit">
<input type="hidden" name="csrf" value="{{ csrf }}">
<button class="btn btn-ghost text-sm" type="submit">Circuit zurücksetzen</button>
</form>
{% endif %}
</div>
</section>
{% if not apply_allowed %}
<div class="card p-4">
<span class="chip chip-bad">apply blockiert</span>
<span class="ml-2 text-sm text-slate-600">{{ apply_block_reason }}</span>
</div> </div>
{% endif %}
<!-- Status zeile -->
<section class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="card p-3">
<div class="text-xs text-slate-500">alert</div>
<div class="mt-1">
{% if last_alert_heartbeat %}<span class="chip chip-ok">live</span>
{% else %}<span class="chip chip-warn">kein Heartbeat</span>{% endif %}
</div>
</div>
<div class="card p-3">
<div class="text-xs text-slate-500">apply</div>
<div class="mt-1">
{% if apply_reachable %}<span class="chip chip-ok">ok</span>
{% else %}<span class="chip chip-bad">down</span>{% endif %}
</div>
</div>
<div class="card p-3">
<div class="text-xs text-slate-500">submit_forms</div>
<div class="mt-1">
{% if submit_forms %}<span class="chip chip-warn">echt senden</span>
{% else %}<span class="chip chip-info">dry-run</span>{% endif %}
</div>
</div>
<div class="card p-3">
<div class="text-xs text-slate-500">Fehler in Serie</div>
<div class="mt-1">
{% if circuit_open %}<span class="chip chip-bad">circuit open</span>
{% elif apply_failures > 0 %}<span class="chip chip-warn">{{ apply_failures }}</span>
{% else %}<span class="chip chip-ok">0</span>{% endif %}
</div>
</div>
</section>
<!-- Filter Panel -->
<details class="card" {% if not filters.rooms_min and not filters.max_rent %}open{% endif %}>
<summary class="px-5 py-3 font-semibold select-none">Eigene Filter</summary>
<form method="post" action="/actions/filters" class="p-5 grid grid-cols-2 md:grid-cols-3 gap-4">
<input type="hidden" name="csrf" value="{{ csrf }}">
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">Zimmer min</label>
<input class="input" name="rooms_min" value="{{ filters.rooms_min if filters.rooms_min is not none else '' }}" placeholder="z.B. 2">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">Zimmer max</label>
<input class="input" name="rooms_max" value="{{ filters.rooms_max if filters.rooms_max is not none else '' }}" placeholder="z.B. 3">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">max Miete (€)</label>
<input class="input" name="max_rent" value="{{ filters.max_rent if filters.max_rent is not none else '' }}" placeholder="1500">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">min Größe (m²)</label>
<input class="input" name="min_size" value="{{ filters.min_size if filters.min_size is not none else '' }}" placeholder="40">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">max Anfahrt morgens (min)</label>
<input class="input" name="max_morning_commute" value="{{ filters.max_morning_commute if filters.max_morning_commute is not none else '' }}" placeholder="50">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">WBS benötigt</label>
<select class="input" name="wbs_required">
<option value="" {% if not filters.wbs_required %}selected{% endif %}>egal</option>
<option value="yes" {% if filters.wbs_required == 'yes' %}selected{% endif %}>ja</option>
<option value="no" {% if filters.wbs_required == 'no' %}selected{% endif %}>nein</option>
</select>
</div>
<div class="col-span-2 md:col-span-3 flex gap-2 pt-2">
<button class="btn btn-primary" type="submit">Filter speichern</button>
<span class="text-xs text-slate-500 self-center">Leer lassen = kein Limit. Filter bestimmen Match-Hervorhebung + Auto-Bewerben.</span>
</div>
</form>
</details>
<!-- Liste aller Wohnungen -->
<section class="card">
<div class="flex items-center justify-between px-4 py-3 border-b border-soft">
<h2 class="font-semibold">Neueste Wohnungen auf inberlinwohnen.de</h2>
<span class="text-xs text-slate-500">{{ flats|length }} gesamt</span>
</div>
<div class="divide-y divide-soft">
{% for item in flats %}
{% set f = item.row %}
<div class="px-4 py-3 flex flex-col md:flex-row md:items-center gap-3 {% if item.matched %}bg-[#f2f8ff]{% endif %}">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<a class="font-medium truncate" href="{{ f.link }}" target="_blank" rel="noopener noreferrer">
{{ f.address or f.link }}
</a>
{% if item.matched %}<span class="chip chip-ok">match</span>{% endif %}
{% if item.last and item.last.success == 1 %}<span class="chip chip-ok">beworben</span>
{% elif item.last and item.last.success == 0 %}<span class="chip chip-bad">apply fehlgeschlagen</span>
{% elif item.last %}<span class="chip chip-warn">läuft</span>{% endif %}
</div>
<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 %}
{% 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.wbs %} · WBS: {{ f.wbs }}{% endif %}
· entdeckt {{ f.discovered_at }}
</div>
{% if item.last and item.last.message %}
<div class="text-xs text-slate-500 mt-1 truncate">↳ {{ item.last.message }}</div>
{% endif %}
</div>
<div class="flex gap-2">
{% if apply_allowed and not (item.last and item.last.success == 1) %}
<form method="post" action="/actions/apply">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="flat_id" value="{{ f.id }}">
<button class="btn btn-primary text-sm" type="submit"
onclick="return confirm('Bewerbung für {{ (f.address or f.link)|e }} ausführen?');">
Bewerben
</button>
</form>
{% endif %}
</div>
</div>
{% else %}
<div class="px-4 py-8 text-center text-slate-500">Noch keine Wohnungen entdeckt.</div>
{% endfor %}
</div>
</section>
{% endblock %} {% endblock %}