wohnungen: drive list info from LLM JSON, tidy header

- Each row's info line now uses the enrichment JSON (rooms, size_sqm,
  rent_total/rent_cold, wbs_required/wbs_type). When enrichment is still
  running we show "Infos werden abgerufen…", on failure "Fehler beim
  Abrufen der Infos"; the scraper fields are no longer rendered in the list
- Move the admin backfill button to the header row as a compact
  "Anreichern (N)" that only appears when there's pending/failed work,
  so it's findable right next to "X gefunden"
- Countdown wobble: new .countdown class forces tabular-nums + 4.2em
  min-width, so neighbours stop shifting every second
- Dot spacing: pull "·" out into its own .sep span so flex gap applies
  on both sides symmetrically

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
EiSiMo 2026-04-21 14:57:11 +02:00
parent eb66284172
commit e0ac869425
4 changed files with 65 additions and 19 deletions

View file

@ -403,9 +403,16 @@ def _wohnungen_context(user) -> dict:
}, filters): }, filters):
continue continue
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}) enrichment_data = None
if f["enrichment_json"]:
try:
enrichment_data = json.loads(f["enrichment_json"])
except Exception:
enrichment_data = None
flats_view.append({"row": f, "last": last, "enrichment": enrichment_data})
rejected_view = db.rejected_flats(uid) rejected_view = db.rejected_flats(uid)
enrichment_counts = db.enrichment_counts()
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)
@ -441,6 +448,7 @@ def _wohnungen_context(user) -> dict:
return { return {
"flats": flats_view, "flats": flats_view,
"rejected_flats": rejected_view, "rejected_flats": rejected_view,
"enrichment_counts": enrichment_counts,
"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,

View file

@ -479,6 +479,23 @@ def flats_needing_enrichment(limit: int = 100) -> list[sqlite3.Row]:
).fetchall()) ).fetchall())
def enrichment_counts() -> dict:
row = _conn.execute(
"""SELECT
COUNT(*) AS total,
SUM(CASE WHEN enrichment_status = 'ok' THEN 1 ELSE 0 END) AS ok,
SUM(CASE WHEN enrichment_status = 'pending' THEN 1 ELSE 0 END) AS pending,
SUM(CASE WHEN enrichment_status = 'failed' THEN 1 ELSE 0 END) AS failed
FROM flats"""
).fetchone()
return {
"total": int(row["total"] or 0),
"ok": int(row["ok"] or 0),
"pending": int(row["pending"] or 0),
"failed": int(row["failed"] or 0),
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Applications # Applications
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -81,7 +81,19 @@
<div class="flex items-center gap-3 text-xs text-slate-500"> <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 class="sep">·</span>
<span>nächste Aktualisierung <span class="countdown" data-countdown-utc="{{ next_scrape_utc }}"></span></span>
{% endif %}
{% if is_admin and (enrichment_counts.pending or enrichment_counts.failed) %}
<span class="sep">·</span>
<form method="post" action="/actions/enrich-all"
hx-post="/actions/enrich-all" hx-target="#wohnungen-body" hx-swap="outerHTML">
<input type="hidden" name="csrf" value="{{ csrf }}">
<button class="btn btn-ghost text-xs" type="submit"
hx-confirm="Altbestand jetzt durch Haiku nachträglich anreichern? Kann einige Minuten dauern.">
Anreichern ({{ enrichment_counts.pending + enrichment_counts.failed }})
</button>
</form>
{% endif %} {% endif %}
<div class="view-toggle ml-2"> <div class="view-toggle ml-2">
<label> <label>
@ -126,11 +138,26 @@
{% endif %} {% endif %}
</div> </div>
<div class="text-xs text-slate-500 mt-0.5"> <div class="text-xs text-slate-500 mt-0.5">
{% if f.rooms %}{{ "%.1f"|format(f.rooms) }} Z{% endif %} {% if f.enrichment_status == 'pending' %}
{% if f.size %} · {{ "%.0f"|format(f.size) }} m²{% endif %} Infos werden abgerufen…
{% if f.total_rent %} · {{ "%.0f"|format(f.total_rent) }} €{% endif %} · <span data-rel-utc="{{ f.discovered_at|iso_utc }}" title="{{ f.discovered_at|de_dt }}"></span>
{% if f.wbs %} · WBS: {{ f.wbs }}{% endif %} {% elif f.enrichment_status == 'failed' %}
· <span data-rel-utc="{{ f.discovered_at|iso_utc }}" title="{{ f.discovered_at|de_dt }}"></span> Fehler beim Abrufen der Infos
· <span data-rel-utc="{{ f.discovered_at|iso_utc }}" title="{{ f.discovered_at|de_dt }}"></span>
{% else %}
{% set e = item.enrichment or {} %}
{% set parts = [] %}
{% if e.rooms %}{% set _ = parts.append('%g Z'|format(e.rooms)) %}{% endif %}
{% if e.size_sqm %}{% set _ = parts.append('%.0f m²'|format(e.size_sqm)) %}{% endif %}
{% set rent = e.rent_total or e.rent_cold %}
{% if rent %}{% set _ = parts.append('%.0f €'|format(rent)) %}{% endif %}
{% if e.wbs_required is true %}
{% set _ = parts.append('WBS: ' ~ (e.wbs_type or 'erforderlich')) %}
{% elif e.wbs_required is false %}
{% set _ = parts.append('ohne WBS') %}
{% endif %}
{{ parts|join(' · ') }}{% if parts %} · {% endif %}<span data-rel-utc="{{ f.discovered_at|iso_utc }}" title="{{ f.discovered_at|de_dt }}"></span>
{% endif %}
</div> </div>
</div> </div>
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
@ -176,18 +203,6 @@
</div> </div>
</section> </section>
{% if is_admin %}
<section class="flex justify-end">
<form method="post" action="/actions/enrich-all"
hx-post="/actions/enrich-all" hx-target="#wohnungen-body" hx-swap="outerHTML">
<input type="hidden" name="csrf" value="{{ csrf }}">
<button class="btn btn-ghost text-xs" type="submit"
hx-confirm="Altbestand jetzt durch Haiku nachträglich anreichern? Kann einige Minuten dauern.">
Altbestand anreichern
</button>
</form>
</section>
{% endif %}
{% if rejected_flats %} {% if rejected_flats %}
<section class="card"> <section class="card">

View file

@ -48,6 +48,12 @@
transition: border-color .15s, box-shadow .15s; } transition: border-color .15s, box-shadow .15s; }
.input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(47,138,224,.18); } .input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(47,138,224,.18); }
.chip { padding: .2rem .7rem; border-radius: 999px; font-size: .75rem; font-weight: 500; display: inline-block; } .chip { padding: .2rem .7rem; border-radius: 999px; font-size: .75rem; font-weight: 500; display: inline-block; }
/* Countdown — tabular digits + fixed width so the text doesn't wobble
while the seconds tick down (3 → 10 → 59 → 1 min). */
.countdown { font-variant-numeric: tabular-nums; display: inline-block;
min-width: 4.2em; text-align: left; }
/* Neutral separator in flex rows so the '·' has equal gap on both sides. */
.sep { color: var(--muted); user-select: none; }
.chip-ok { background: #e4f6ec; color: #1f8a4a; border: 1px solid #b7e4c7; } .chip-ok { background: #e4f6ec; color: #1f8a4a; border: 1px solid #b7e4c7; }
.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; }