Tints districts the user's Bezirk filter has EXCLUDED in light yellow
(#fde68a, fillOpacity 0.35) so the active selection is obvious from
the map alone — and you can see at a glance whether a "rausgefiltert"
flat fell in a no-go district. When no district filter is set the
overlay stays off entirely (nothing is excluded).
Wiring:
- Berlin Bezirke GeoJSON checked in at web/static/berlin-districts.geojson
(12 features, name property matches our DISTRICTS list 1:1, props
stripped down to {name} and minified — 312KB raw, ~80KB gzipped).
- Route exposes the user's selected_districts_csv to the template.
- The flats-map-data <script> carries it on data-selected-districts so
it flows in alongside csrf and the marker payload.
- map.js fetches the GeoJSON once (cache normally), keeps the layer in
a module-level reference, and re-styles it via setStyle() on every
swap (cheap). Marker layer is kicked to the front so pins always
paint above the shaded polygons. fingerprintOf now also folds in
the selected-districts CSV so a Bezirk-only filter change still
triggers a re-render.
Branch only — kept off main while we see if this reads well.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- notifications: round sqm_price to whole € in Telegram match messages
(was emitting raw float like "12.345614 €/m²").
- wohnungen: remove the admin-only "Bilder nachladen (N)" button. It
flickered into view whenever a freshly-scraped flat was still in
pending state, which was effectively random from the user's point of
view, and the manual backfill it triggered isn't needed anymore — new
flats are auto-enriched at scrape time. Also drops the dead helpers
it was the sole caller of: enrichment.kick_backfill,
enrichment._backfill_runner, db.flats_needing_enrichment,
db.enrichment_counts.
- lightbox: the modal didn't appear because Tailwind's Play CDN injects
its own .hidden { display: none } rule at runtime, which kept fighting
our class toggle. Switch the show/hide to inline style.display so no
external stylesheet can mask it. Single-class .lightbox now only owns
the layout — the initial-hidden state is on the element via
style="display:none".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Below "Abgelehnte Wohnungen", surface flats that survived the time
filter and aren't rejected but failed at least one of the user's
filters. Same collapsed-card style. Action buttons are replaced by
chips naming each failed dimension — "Zimmer", "Preis", "Größe",
"WBS", "Bezirk" — so it's obvious which constraint to relax.
Refactored matching: flat_matches_filter now delegates to a new
flat_filter_failures(flat, f) that returns the failed-dimension
labels (empty list = full match). rooms_min and rooms_max collapse
to a single "Zimmer" chip; reasons emit in stable _REASON_ORDER for
consistent rendering. The section is suppressed entirely when the
user has no filters set, since "everything matches" makes the chips
meaningless.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
base.html shrinks from a 150-line inline stylesheet to a single <link>;
the CSS moves to web/static/app.css byte-for-byte so there's no visual
change, but the stylesheet is now cacheable independently of the HTML.
Drop hx-on::before-request="this.disabled=true" from the Bewerben /
Ablehnen buttons — it duplicates hx-disabled-elt="find button" on the
parent form, which htmx already applies per request.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Showing "läuft…" inside the Bewerben button duplicated the "läuft…"
chip beside the address. Drop the text swap; keep the original labels
"Bewerben" / "Ablehnen", just disable + grey both buttons (the existing
.btn[disabled] styling handles that) while finished_at is null.
On success, both buttons continue to hide — only the "beworben" chip remains.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the Bewerben-hide behavior: once item.last.success == 1, the
Ablehnen form/button doesn't render either, so the row just shows the
"beworben" chip without any actions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
1. New /admin route with sub-tabs (Protokoll, Benutzer) for admins.
Top nav: "Protokoll" dropped, "Admin" added right of Einstellungen.
/logs and /einstellungen/benutzer issue 301 redirects to the new paths.
Benutzer is no longer part of Einstellungen sub-nav.
2. User_filters.max_age_hours (migration v6) — new dropdown (1–10 h /
beliebig) under Einstellungen → Filter; Wohnungen list drops flats
older than the cutoff by discovered_at.
3. Header shows "aktualisiert vor X s" instead of a countdown. Template
emits data-counter-up-utc with last_alert_heartbeat; app.js ticks up
each second. When a scrape runs, the heartbeat updates and the HTMX
swap resets the counter naturally.
4. Chevron state synced after HTMX swaps: panes preserved via hx-preserve
keep the user's open/closed state, and the sibling button's .open
class is re-applied by syncFlatExpandState() on afterSwap — previously
a scroll-triggered poll would flip the chevron back to closed while
the pane stayed open.
5. "Final absenden" footer removed from the profile page (functionality
is unchanged, the switch still sits atop Wohnungen).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per user request, the LLM is no longer asked to extract rooms/size/rent/WBS —
those come from the inberlinwohnen.de scraper which is reliable. Haiku is now
used for one narrow job: pick which <img> URLs from the listing page are
actual flat photos (vs. logos, badges, ads, employee portraits). On any LLM
failure the unfiltered candidate list passes through.
Image dedup runs in two tiers:
1. SHA256 of bytes — drops different URLs that point to byte-identical files
2. Perceptual hash (Pillow + imagehash, Hamming distance ≤ 5) — drops the
"same image at a different resolution" duplicates from srcset / CDN
variants that were filling galleries with 2–4× copies
UI:
- Wohnungsliste falls back to scraper-only display (rooms/size/rent/wbs)
- Detail panel only shows images + "Zur Original-Anzeige →"; description /
features / pros & cons / kv table are gone
- Per-row "erneut versuchen" link + the "analysiert…/?" status chips were
tied to LLM extraction and are removed; the header "Bilder nachladen (N)"
button still surfaces pending/failed batches for admins
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Many listings die or 404 within hours of being published, and several
landlord pages render their stats via JS that our Playwright fetch doesn't
reliably catch. In those cases the LLM correctly returns nulls — but we'd
then show "2 Z · vor 30 min" and lose the m²/€/WBS info that the
inberlinwohnen.de scraper had captured authoritatively.
The list now coalesces: e.rooms / e.size_sqm / e.rent_total or rent_cold /
e.wbs_required take precedence; when null we fall back to f.rooms, f.size,
f.total_rent, f.wbs respectively. Boolean wbs_required uses `is sameas`
so an explicit `false` (no-WBS) from the LLM is preserved instead of being
treated as missing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each enrichment failure now records {"_error": "...", "_step": "..."} into
enrichment_json, mirrors the message into the errors log (visible in
/logs/protokoll), and the list shows the cause as a tooltip on the
"Fehler beim Abrufen der Infos" text. Admins also get a "erneut versuchen"
link per failed row that re-queues just that flat (POST /actions/enrich-flat).
The pipeline raises a typed EnrichmentError per step (fetch / llm / crash)
so future failure modes don't get swallowed as a silent "failed".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 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>
apply service
- POST /internal/fetch-listing: headless Playwright fetch of a listing URL,
returns {html, image_urls[], final_url}. Uses the same browser
fingerprint/profile as the apply run so bot guards don't kick in
web service
- New enrichment pipeline (web/enrichment.py):
/internal/flats → upsert → kick() enrichment in a background thread
1. POST /internal/fetch-listing on apply
2. llm.extract_flat_details(html, url) — Haiku tool-use call returns
structured JSON (address, rooms, rent, description, pros/cons, etc.)
3. Download each image directly to /data/flats/<slug>/NN.<ext>
4. Persist enrichment_json + image_count + enrichment_status on the flat
- llm.py: minimal Anthropic /v1/messages wrapper, no SDK
- DB migration v5 adds enrichment_json/_status/_updated_at + image_count
- Admin "Altbestand anreichern" button (POST /actions/enrich-all) queues
backfill for all pending/failed rows; runs in a detached task
- GET /partials/wohnung/<id> renders _wohnung_detail.html
- GET /flat-images/<slug>/<n> serves the downloaded image
UI
- Chevron on each list row toggles an inline detail pane (HTMX fetch on
first open, hx-preserve keeps it open across the 3–30 s polls)
- CSS .flat-gallery normalises image tiles to a 4/3 aspect with object-fit:
cover so different source sizes align cleanly
- "analysiert…" / "?" chips on the list reflect enrichment_status
Config
- ANTHROPIC_API_KEY + ANTHROPIC_MODEL wired into docker-compose's web
service (default model: claude-haiku-4-5-20251001)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- /actions/apply now no-ops (returns fresh partial) when a running
application exists for this user+flat, or when a previous one succeeded.
The list button was already visually disabled; this closes the direct-POST
and double-click loopholes
- Drop the one-line error message under flat entries in the list
(bewerbung_detail still shows the full message + the forensic ZIP report)
- Strip "min morgens" commute chip from the list; alert._flat_payload sends
an empty connectivity dict so Maps.calculate_score is no longer called on
every flat. Maps.calculate_score + Flat.connectivity stay in the codebase
for easy re-enable (one-line swap in _flat_payload)
- List entry shows "vor 23 min" instead of "entdeckt vor 23 min"
- Bitwarden: rename profile email/immomio fields to opaque names
(contact_addr, immomio_login, immomio_secret) + add data-bwignore across
every settings form / input. Server-side update_profile maps the new
field names back to the existing DB columns
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- map_points payload now carries flat id, per-user status, can_apply, is_running
- Popup titles link to the listing; status chip mirrors the list (beworben /
läuft… / fehlgeschlagen); Bewerben + Ablehnen submit via the same HTMX
endpoints as the list, re-swapping #wohnungen-body
- csrf token rides on the script[data-csrf] sibling of #flats-map
- popupopen → htmx.process(popupEl) so hx-* on freshly injected DOM binds
- site-style .map-popup-* CSS hooked into Leaflet's popup wrapper
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Remove the admin "Koordinaten nachladen" button, /actions/backfill-coords
endpoint, geocode.py, googlemaps dep, GMAPS_API_KEY plumbing in the web
service, and the map diagnostic line. Going-forward geocoding happens in
alert on scrape; upsert_flat backfill on re-submit remains for edge cases
- Remove the OpenRailwayMap transit overlay (visually noisy); keep CartoDB
Voyager as the sole basemap
- Profile + notifications forms get autocomplete="off" + data-lpignore +
data-1p-ignore at form and field level to keep password managers from
popping open on /einstellungen; immomio_password uses autocomplete=new-password
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Surface "X/Y passende Wohnungen mit Koordinaten" on the Karte view +
admin-only "Koordinaten nachladen" button (POST /actions/backfill-coords)
that geocodes missing flats via Google Maps directly from the web container
- Add googlemaps dep + GMAPS_API_KEY env to web service
- Light console.log in map.js ("rendering N/M markers", "building Leaflet…")
so the browser DevTools shows what's happening
- Drop e-mail channel from notifications UI, notify dispatcher, and _alert_status;
coerce legacy 'email' channel rows back to 'ui' on save
- Countdown said "Aktualisierung läuft…" next to "nächste Aktualisierung" →
shortened to "aktualisiere…"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- #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>
* Alarm-Status ist jetzt nur 'aktiv' wenn ein echter Push-Channel (Telegram
mit Token+Chat oder E-Mail mit Adresse) konfiguriert ist. UI-only zählt
nicht mehr als eingerichteter Alarm.
* Ablehnen-Button in der Wohnungsliste: flat_rejections (migration v4)
speichert pro-User-Ablehnungen, abgelehnte Flats fallen aus Liste und
Karte raus. Wiederholbar pro User unabhängig.
* Footer 'Programmiert für Annika ♥' erscheint nur auf Seiten, wenn annika
angemeldet ist.
* Map: Hinweistext unter leerer Karte entfernt; alle Zoom-Mechanismen
deaktiviert (Scrollrad, Doppelklick, Box, Touch, Tastatur, +/- Buttons).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* flats: new lat/lng columns (migration v3); alert geocodes every new flat
through googlemaps and ships coords in the payload
* web: CSP extended for unpkg (leaflet.css) + tile.openstreetmap.org
* Wohnungen tab: Liste/Karte view toggle (segmented, CSS-only via :has(),
selection persisted in localStorage). Karte shows passende flats as Pins
on an OSM tile map; Popup per Pin mit Adresse, Zimmer/m²/€ und Link
* Top-strip toggles are now proper iOS-style toggle switches (single
rounded knob sliding in a pill, red when on), no descriptive subtitle
* Alarm-Karte verlinkt jetzt auf /einstellungen/benachrichtigungen
(Filter-Karte bleibt /einstellungen/filter)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Einstellungen → Profil: WBS-Typ jetzt <select> mit WBS 100/140/160/180/220
* Einstellungen → Filter: Zimmer min/max als number-Feld mit step=0.5
(2.5-Zimmer-Wohnungen sauber eingebbar)
* Wohnungen-Top-Leiste: Segmented-Toggle (ein zusammenhängender Kippschalter)
für die beiden Schalter, keine einzelnen Radio-Pills mehr
* Trockenmodus umbenannt in 'Final absenden' (positive Polarität: An=echt
senden). Bestätigungsdialog beim Einschalten.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* apply: Recorder.step_snap(page, name) captures both a JPEG screenshot and
the page HTML for every major moment; every provider now calls step_snap at
each logical step so failure reports contain the exact DOM and rendered
state at every stage of the flow
* ZIP report: each snapshot becomes snapshots/NN_<label>.jpg +
snapshots/NN_<label>.html for AI-assisted debugging
* web: Wohnungsliste zeigt nur noch Flats, die die eigenen Filter treffen;
Match-Chip entfernt (Liste ist jetzt implizit matchend)
* UI komplett auf Deutsch: Protokoll statt Logs, Administrator statt admin,
Trockenmodus statt dry-run, Automatik pausiert statt circuit open,
Alarm statt Alert, Abmelden statt Logout
* Wohnungen-Header: Zeile 1 Info (Alarm + Filter), Zeile 2 Schalter mit
echten Radio-Paaren (An/Aus) für Automatisch bewerben und Trockenmodus;
hx-confirm auf den kritischen Radios; per-form CSS für sichtbaren Check-State
* Protokoll: von/bis-Datumsfilter (Berliner Zeit) + CSV-Download
(/logs/export.csv) mit UTC + lokaler Zeit
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Wohnungen header: single slim row with Alert status · Filter summary ·
Auto-Bewerben toggle · Trockenmodus toggle. Big filter panel removed —
filters live only in /einstellungen/filter.
* Alert status: 'nicht eingerichtet' until the user has actual filters (+
valid notification creds if telegram/email). 'aktiv' otherwise.
* Logs tab: admin-only (gated both in layout and server-side). Shows merged
audit + errors across all users, sorted newest-first, capped at 300.
* Apply, auto-apply, trockenmodus and circuit reset buttons post via HTMX and
swap the Wohnungen body. While any application is still running for the
user the poll interval drops from 30s to 3s so status flips to 'beworben'
or 'fehlgeschlagen' almost immediately.
* Browser tab title is now always 'lazyflat'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* 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>