bewerben UX: instant feedback; drop forensics detail; partner feature

1. Bewerben button: hx-disabled-elt + hx-on::before-request flips the
   text to "läuft…" and disables the button the moment the confirm is
   accepted. .btn[disabled] now renders at 55% opacity with
   not-allowed cursor. Existing 3s poll interval picks up the running
   state for the chip beside the address.

2. Bewerbungen tab: delete the /bewerbungen/<id> forensics detail page
   + template entirely. The list now shows a plain "Report (ZIP)"
   button for every row regardless of success — same download route
   (/bewerbungen/<id>/report.zip), same visual style. External link to
   the listing moved onto the address itself.

3. Verified retention: web/retention.py runs cleanup_retention() hourly,
   which DELETEs errors + audit_log rows older than RETENTION_DAYS (14)
   and nulls applications.forensics_json for older rows. No code change
   needed.

4. Partner feature. Migration v8 adds partnerships(from_user_id,
   to_user_id, status, created_at, accepted_at). Einstellungen →
   Partner lets users:
   - send a request by username
   - accept / decline incoming requests
   - withdraw outgoing requests
   - unlink the active partnership

   A user can only have one accepted partnership; accepting one wipes
   stale pending rows involving either side. On the Wohnungen list, if
   the partner has applied to a flat, a small primary-colored circle
   with the partner's first-name initial sits on the top-right of the
   Bewerben button; if they've rejected it, the badge sits on Ablehnen.
   Badge is hover-tooltipped with the partner's name + verb.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
EiSiMo 2026-04-21 18:18:24 +02:00
parent 3bb04210c4
commit a212dff4d9
8 changed files with 379 additions and 166 deletions

View file

@ -0,0 +1,100 @@
<h2 class="font-semibold mb-2">Partner</h2>
<p class="text-sm text-slate-600 mb-4">
Verknüpfe deinen Account mit einem anderen Benutzer. Ihr seht dann gegenseitig,
welche Wohnungen der/die andere schon beworben oder abgelehnt hat.
</p>
{% if partner_flash %}
<div class="chip mb-4
{% if partner_flash in ('sent','accepted','declined','unlinked') %}chip-ok
{% elif partner_flash in ('nouser','exists','accept_failed') %}chip-bad
{% endif %}">
{% if partner_flash == 'sent' %}Anfrage gesendet.
{% elif partner_flash == 'accepted' %}Partnerschaft aktiv.
{% elif partner_flash == 'declined' %}Anfrage abgelehnt.
{% elif partner_flash == 'unlinked' %}Partnerschaft beendet.
{% elif partner_flash == 'nouser' %}Benutzer nicht gefunden.
{% elif partner_flash == 'exists' %}Bereits eine Anfrage oder Verknüpfung vorhanden.
{% elif partner_flash == 'accept_failed' %}Annahme fehlgeschlagen.
{% endif %}
</div>
{% endif %}
{% if partner %}
<section class="card p-4 mb-6 flex items-center justify-between gap-3">
<div>
<div class="text-xs uppercase tracking-wide text-slate-500">Verknüpft mit</div>
<div class="text-sm font-medium">
{{ partner_profile.firstname or partner.username }}
<span class="text-slate-500">({{ partner.username }})</span>
</div>
</div>
<form method="post" action="/actions/partner/unlink"
onsubmit="return confirm('Partnerschaft wirklich beenden?');">
<input type="hidden" name="csrf" value="{{ csrf }}">
<button class="btn btn-danger text-sm" type="submit">Trennen</button>
</form>
</section>
{% else %}
{% if incoming_requests %}
<section class="card mb-6">
<div class="px-4 py-2 border-b border-soft text-xs uppercase tracking-wide text-slate-500">
Eingegangene Anfragen
</div>
<div class="divide-y divide-soft">
{% for r in incoming_requests %}
<div class="px-4 py-3 flex items-center justify-between gap-3">
<span class="text-sm">{{ r.from_username }}</span>
<div class="flex gap-2">
<form method="post" action="/actions/partner/accept">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="request_id" value="{{ r.id }}">
<button class="btn btn-primary text-sm" type="submit">Annehmen</button>
</form>
<form method="post" action="/actions/partner/decline">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="request_id" value="{{ r.id }}">
<button class="btn btn-ghost text-sm" type="submit">Ablehnen</button>
</form>
</div>
</div>
{% endfor %}
</div>
</section>
{% endif %}
{% if outgoing_requests %}
<section class="card mb-6">
<div class="px-4 py-2 border-b border-soft text-xs uppercase tracking-wide text-slate-500">
Ausgehende Anfragen
</div>
<div class="divide-y divide-soft">
{% for r in outgoing_requests %}
<div class="px-4 py-3 flex items-center justify-between gap-3">
<span class="text-sm">{{ r.to_username }} <span class="text-slate-500">(ausstehend)</span></span>
<form method="post" action="/actions/partner/decline">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="request_id" value="{{ r.id }}">
<button class="btn btn-ghost text-sm" type="submit">Zurückziehen</button>
</form>
</div>
{% endfor %}
</div>
</section>
{% endif %}
<section class="card p-4">
<h3 class="font-semibold mb-3">Neue Anfrage</h3>
<form method="post" action="/actions/partner/request"
class="flex items-end gap-3 flex-wrap"
autocomplete="off" data-lpignore="true" data-1p-ignore data-bwignore data-form-type="other">
<input type="hidden" name="csrf" value="{{ csrf }}">
<div class="flex-1 min-w-0">
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">Benutzername</label>
<input class="input" name="partner_username" required
autocomplete="off" data-lpignore="true" data-1p-ignore data-bwignore>
</div>
<button class="btn btn-primary text-sm" type="submit">Anfrage senden</button>
</form>
</section>
{% endif %}

View file

@ -149,17 +149,25 @@
{% if apply_allowed and not (item.last and item.last.success == 1) %}
{% set is_running = item.last and item.last.finished_at is none %}
<form method="post" action="/actions/apply"
hx-post="/actions/apply" hx-target="#wohnungen-body" hx-swap="outerHTML">
class="btn-with-badge"
hx-post="/actions/apply" hx-target="#wohnungen-body" hx-swap="outerHTML"
hx-disabled-elt="find button">
<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"
{% if is_running %}disabled{% endif %}
hx-confirm="Bewerbung für {{ (f.address or f.link)|e }} starten?">
hx-confirm="Bewerbung für {{ (f.address or f.link)|e }} starten?"
hx-on::before-request="this.textContent='läuft…'; this.disabled=true">
{% if is_running %}läuft…{% else %}Bewerben{% endif %}
</button>
{% if partner and f.id in partner.applied_flat_ids %}
<span class="partner-badge"
title="{{ partner.name }} hat sich bereits beworben">{{ partner.initial }}</span>
{% endif %}
</form>
{% endif %}
<form method="post" action="/actions/reject"
class="btn-with-badge"
hx-post="/actions/reject" hx-target="#wohnungen-body" hx-swap="outerHTML">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="flat_id" value="{{ f.id }}">
@ -167,6 +175,10 @@
hx-confirm="Ablehnen und aus der Liste entfernen?">
Ablehnen
</button>
{% if partner and f.id in partner.rejected_flat_ids %}
<span class="partner-badge"
title="{{ partner.name }} hat abgelehnt">{{ partner.initial }}</span>
{% endif %}
</form>
<button type="button" class="flat-expand-btn" aria-label="Details"
data-flat-id="{{ f.id }}">

View file

@ -38,6 +38,20 @@
.btn { border-radius: 9px; padding: 0.45rem 0.95rem; font-weight: 500;
transition: background 0.15s, box-shadow 0.15s, transform 0.05s; display: inline-block; }
.btn:active { transform: translateY(1px); }
.btn:disabled, .btn[disabled] { opacity: .55; cursor: not-allowed; pointer-events: none; }
/* Partner-Aktionsbadge — kleiner Kreis mit dem Anfangsbuchstaben oben rechts am Button */
.btn-with-badge { position: relative; display: inline-block; }
.partner-badge {
position: absolute; top: -6px; right: -6px;
width: 18px; height: 18px; border-radius: 9999px;
background: var(--primary); color: #fff;
font-size: 10px; font-weight: 700; line-height: 1;
display: inline-flex; align-items: center; justify-content: center;
border: 2px solid #fff;
box-shadow: 0 1px 2px rgba(16,37,63,.25);
pointer-events: auto;
}
.btn-primary { background: var(--primary); color: white; box-shadow: 0 1px 2px rgba(47,138,224,.25); }
.btn-primary:hover { background: var(--primary-hover); }
.btn-danger { background: var(--danger); color: white; }

View file

@ -1,127 +0,0 @@
{% extends "_layout.html" %}
{% block content %}
<a href="/bewerbungen" class="text-sm">← zurück zu den Bewerbungen</a>
<section class="card p-5">
<div class="flex items-center gap-2 flex-wrap mb-3">
<h2 class="font-semibold text-lg">Bewerbung #{{ application.id }}</h2>
{% if application.success == 1 %}<span class="chip chip-ok">erfolgreich</span>
{% elif application.success == 0 %}<span class="chip chip-bad">fehlgeschlagen</span>
{% else %}<span class="chip chip-warn">läuft</span>{% endif %}
<span class="chip chip-info">{% if application.triggered_by == 'auto' %}automatisch{% else %}manuell{% endif %}</span>
{% 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>
{% else %}<span class="chip chip-info">nicht abgeschickt</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 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">gestartet:</span> {{ application.started_at|de_dt }}</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 %}
</div>
</section>
<section class="card p-5">
<details>
<summary class="font-semibold">Profil-Snapshot zum Bewerbungszeitpunkt</summary>
<pre class="mono whitespace-pre-wrap break-all mt-3 p-3 bg-[#f6fafd] rounded-lg border border-soft">{{ profile_snapshot | tojson(indent=2) }}</pre>
</details>
</section>
{% if forensics %}
<section class="card p-5 space-y-4">
<h3 class="font-semibold">Forensik</h3>
<details open>
<summary class="font-medium">Step-Log ({{ forensics.steps|length }} Einträge, {{ forensics.duration_s }} s)</summary>
<div class="mono mt-2 space-y-0.5">
{% for s in forensics.steps %}
<div class="{% if s.status != 'ok' %}text-[#b8404e]{% else %}text-slate-700{% endif %}">
[{{ "%.2f"|format(s.ts) }}s] {{ s.step }} {% if s.status != 'ok' %}({{ s.status }}){% endif %}
{% if s.detail %}— {{ s.detail }}{% endif %}
</div>
{% endfor %}
</div>
</details>
{% if forensics.screenshots %}
<details>
<summary class="font-medium">Momentaufnahmen ({{ forensics.screenshots|length }})</summary>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-3">
{% for s in forensics.screenshots %}
<div class="border border-soft rounded-lg p-2 space-y-2">
<div class="text-xs text-slate-500">{{ s.label }} @ {{ "%.2f"|format(s.ts) }}s — {{ s.url }}</div>
{% if s.b64_jpeg %}
<img src="data:image/jpeg;base64,{{ s.b64_jpeg }}" class="w-full rounded" alt="{{ s.label }}">
{% endif %}
{% if s.html %}
<details>
<summary class="text-xs text-slate-500">Quellcode anzeigen ({{ s.html_size }} B)</summary>
<pre class="mono whitespace-pre-wrap break-all mt-2 p-2 bg-[#f6fafd] rounded border border-soft max-h-72 overflow-auto">{{ s.html }}</pre>
</details>
{% endif %}
</div>
{% endfor %}
</div>
</details>
{% endif %}
{% if forensics.console %}
<details>
<summary class="font-medium">Browser-Konsole ({{ forensics.console|length }})</summary>
<div class="mono mt-2 space-y-0.5">
{% for c in forensics.console %}
<div class="text-slate-700">[{{ "%.2f"|format(c.ts) }}s] [{{ c.type }}] {{ c.text }}</div>
{% endfor %}
</div>
</details>
{% endif %}
{% if forensics.errors %}
<details open>
<summary class="font-medium text-[#b8404e]">Browser-Errors ({{ forensics.errors|length }})</summary>
<div class="mono mt-2 space-y-0.5 text-[#b8404e]">
{% for e in forensics.errors %}
<div>[{{ "%.2f"|format(e.ts) }}s] {{ e.message }}</div>
{% endfor %}
</div>
</details>
{% endif %}
{% if forensics.network %}
<details>
<summary class="font-medium">Netzwerk ({{ forensics.network|length }})</summary>
<div class="mono mt-2 space-y-0.5">
{% for n in forensics.network %}
<div class="text-slate-700 break-all">
[{{ "%.2f"|format(n.ts) }}s]
{% if n.kind == 'response' %}← {{ n.status }} {{ n.url }}
{% if n.body_snippet %}<div class="pl-4 text-slate-500">{{ n.body_snippet[:500] }}</div>{% endif %}
{% else %}→ {{ n.method }} {{ n.url }} [{{ n.resource_type }}]
{% endif %}
</div>
{% endfor %}
</div>
</details>
{% endif %}
{% if forensics.final_html %}
<details>
<summary class="font-medium">Page HTML ({{ forensics.final_html|length }} B)</summary>
<pre class="mono whitespace-pre-wrap break-all mt-3 p-3 bg-[#f6fafd] rounded-lg border border-soft max-h-96 overflow-auto">{{ forensics.final_html }}</pre>
</details>
{% endif %}
</section>
{% elif application.finished_at %}
<section class="card p-5 text-sm text-slate-500">
Forensik für diese Bewerbung ist nicht mehr verfügbar (älter als Retention-Zeitraum).
</section>
{% endif %}
{% endblock %}

View file

@ -7,27 +7,25 @@
</div>
<div class="divide-y divide-soft">
{% for a in applications %}
<div class="px-4 py-3 text-sm">
<div class="flex items-center gap-2 flex-wrap">
{% if a.success == 1 %}<span class="chip chip-ok">ok</span>
{% elif a.success == 0 %}<span class="chip chip-bad">fehlgeschlagen</span>
{% else %}<span class="chip chip-warn">läuft</span>{% endif %}
<span class="chip chip-info">{% if a.triggered_by == 'auto' %}automatisch{% else %}manuell{% endif %}</span>
{% 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>
{% else %}<span class="chip chip-info">nicht abgeschickt</span>{% endif %}
<span class="text-slate-500 text-xs ml-auto"
title="{{ a.started_at|de_dt }}">{{ a.started_at|de_dt }}</span>
<div class="px-4 py-3 text-sm flex flex-col md:flex-row md:items-center gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
{% if a.success == 1 %}<span class="chip chip-ok">ok</span>
{% elif a.success == 0 %}<span class="chip chip-bad">fehlgeschlagen</span>
{% else %}<span class="chip chip-warn">läuft</span>{% endif %}
<span class="chip chip-info">{% if a.triggered_by == 'auto' %}automatisch{% else %}manuell{% endif %}</span>
{% 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>
{% else %}<span class="chip chip-info">nicht abgeschickt</span>{% endif %}
<span class="text-slate-500 text-xs ml-auto"
title="{{ a.started_at|de_dt }}">{{ a.started_at|de_dt }}</span>
</div>
<div class="mt-1 truncate">
#{{ a.id }} — <a href="{{ a.url }}" target="_blank" rel="noopener">{{ a.address or a.url }}</a>
</div>
{% if a.message %}<div class="text-xs text-slate-500 mt-0.5 truncate">{{ a.message }}</div>{% endif %}
</div>
<div class="mt-1 truncate">
<a href="/bewerbungen/{{ a.id }}">#{{ a.id }} — {{ a.address or a.url }}</a>
</div>
{% 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 %}
<a class="btn btn-ghost text-sm" href="/bewerbungen/{{ a.id }}/report.zip">Report (ZIP)</a>
</div>
{% else %}
<div class="px-4 py-8 text-center text-slate-500">Noch keine Bewerbungen.</div>

View file

@ -2,7 +2,7 @@
{% block content %}
<section class="card">
<nav class="flex flex-wrap border-b border-soft px-4">
{% set sections = [('profil','Profil'),('filter','Filter'),('benachrichtigungen','Benachrichtigungen'),('account','Account')] %}
{% set sections = [('profil','Profil'),('filter','Filter'),('benachrichtigungen','Benachrichtigungen'),('partner','Partner'),('account','Account')] %}
{% for key, label in sections %}
<a href="/einstellungen/{{ key }}"
class="tab {% if section == key %}active{% endif %}">{{ label }}</a>
@ -13,6 +13,7 @@
{% if section == 'profil' %}{% include "_settings_profil.html" %}
{% elif section == 'filter' %}{% include "_settings_filter.html" %}
{% elif section == 'benachrichtigungen' %}{% include "_settings_notifications.html" %}
{% elif section == 'partner' %}{% include "_settings_partner.html" %}
{% elif section == 'account' %}{% include "_settings_account.html" %}
{% endif %}
</div>