Commit graph

40 commits

Author SHA1 Message Date
eb73b5e415 correctness batch: atomic writes, task refs, hmac, import-star, pickle
Per review §2:

- web/db.py: new _tx() context manager wraps multi-statement writers in
  BEGIN IMMEDIATE … COMMIT/ROLLBACK (our connections run in autocommit
  mode, so plain `with _lock:` doesn't give atomicity). partnership_accept
  (UPDATE + DELETE) and cleanup_retention (3 deletes/updates) now use it.
- Fire-and-forget tasks: add module-level _bg_tasks sets in web/app.py and
  web/enrichment.py. A _spawn() helper holds a strong ref until the task
  finishes so the GC can't drop it mid-flight (CPython's event loop only
  weakly references pending tasks).
- apply/main.py: require_api_key uses hmac.compare_digest, matching web's
  check. Also imports now use explicit names instead of `from settings *`.
- apply/language.py: replace `from settings import *` + `from paths import *`
  with explicit imports — this is the pattern that caused the LANGUAGE
  NameError earlier.
- alert/utils.py: pickle-based hash_any_object → deterministic JSON+sha256.
  Cheaper, portable across Python versions, no pickle attack surface.
- web/notifications.py: /fehler links repointed to /bewerbungen (the
  former page doesn't exist).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:14:26 +02:00
77098c82df perf: one grouped query for latest per-flat application, not N
_wohnungen_context was calling last_application_for_flat(uid, f.id)
inside the flats loop — at 100 flats that's 101 queries per poll, and
with running applications the page polls every 3s. New
db.latest_applications_by_flat(user_id) returns {flat_id: row} via a
single grouped self-join.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:06:48 +02:00
ebb11178e7 chore: sweep dead code across all three services
Per review §1 — verified no callers before each deletion:

- _next_scrape_utc (context dict key never read by any template)
- ALERT_SCRAPE_INTERVAL_SECONDS settings import (only _next_scrape_utc read it)
- alert/paths.py (imported by nothing)
- alert/settings.py LANGUAGE (alert doesn't use translations.toml)
- alert/main.py: the vestigial `c = {}` connectivity dict, the comment
  about re-enabling it, and the entire connectivity block in
  _flat_payload — the web-side columns stay NULL on insert now
- alert/maps.py: DESTINATIONS, calculate_score, _get_next_weekday,
  _calculate_transfers (only geocode is used in the scraper)
- alert/flat.py: connectivity + display_address properties,
  _connectivity field, unused datetime import
- apply/utils.py str_to_preview (no callers) — file removed
- web/matching.py: max_morning_commute + commute check
- web/app.py: don't pass connectivity dict into flat_matches_filter,
  don't write email_address through update_notifications
- web/db.py: get_error (no callers); drop kill_switch,
  max_morning_commute, email_address from their allowed-sets so they're
  not writable through update_* anymore
- web/settings.py + docker-compose.yml: SMTP_HOST/PORT/USERNAME/PASSWORD/
  FROM/STARTTLS (notifications.py is telegram-only now)

DB columns themselves (kill_switch, email_address, max_morning_commute,
connectivity_morning_time, connectivity_night_time) stay in the schema
— SQLite can't drop them cheaply and they're harmless.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:06:05 +02:00
617c76cb54 db: thread-local SQLite connections + busy_timeout
The module-level _conn was being hit from FastAPI handlers, the retention
daemon thread, and asyncio.to_thread workers simultaneously. Sharing a
single sqlite3.Connection across threads is unsafe (cursors collide)
even with check_same_thread=False and WAL. The writer _lock didn't cover
readers, so a reader cursor could race a writer mid-statement.

Switch to threading.local(): each thread gets its own Connection via
_get_conn(). WAL handles concurrent readers/writer at the DB level;
busy_timeout=5000 absorbs short-lived "database is locked" when two
threads both try to BEGIN IMMEDIATE. The write-serialising _lock stays
— it keeps multi-statement writer blocks atomic and avoids busy-loop on
concurrent writers.

External access via db._conn replaced with a new db.has_running_application
helper (the only caller outside db.py).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:01:27 +02:00
6bd7a4306a wohnungen actions: keep label text, disable both buttons while running
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>
2026-04-21 18:43:17 +02:00
72d9f808e2 hide Ablehnen button once a flat has been successfully applied to
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>
2026-04-21 18:40:42 +02:00
f5b4523b77 fix apply-click feedback: create application row synchronously
The Bewerben button wasn't flipping to "läuft…" until the next HTMX
poll because the DB row with finished_at=NULL was being inserted
inside the background thread — which had barely started by the time
the route already returned the re-rendered partial.

Split the work: _kick_apply now runs db.start_application() on the
request thread so the row exists before the response hits the wire.
Only the long-running Playwright call + finish_application are
offloaded to a worker via asyncio.create_task + to_thread. The new
_finish_apply_background re-reads prefs when it lands so circuit-
breaker state is current.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:39:42 +02:00
a212dff4d9 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>
2026-04-21 18:18:24 +02:00
3bb04210c4 secrets tab, drop commute filter, favicon, robust error reports
1. Admin → Geheimnisse sub-tab lets you edit ANTHROPIC_API_KEY +
   BERLIN_WOHNEN_USERNAME/PASSWORD at runtime. Migration v7 adds a
   secrets(key,value,updated_at) table; startup seeds missing keys from
   env (idempotent). web reads secrets DB-first (env fallback) via
   llm._api_key(); alert fetches them from web /internal/secrets on each
   scan, passes them into Scraper(). Rotating creds no longer needs a
   redeploy.
   Masked display: 6 leading + 4 trailing chars, "…" in the middle.
   Blank form fields leave the stored value untouched.

2. Drop the max_morning_commute filter from UI + server + FILTER_KEYS +
   filter summary (the underlying Maps.calculate_score code stays for
   potential future re-enable).

3. /static/didi.webp wired as favicon via <link rel="icon"> in base.html.

4. apply.open_page wraps page.goto in try/except so a failed load still
   produces a "goto.failed" step + screenshot instead of returning an
   empty forensics blob. networkidle + post-submission sleep are also
   made best-effort. The error ZIP export already writes screenshot+HTML
   per step and final_html — with this change every apply run leaves a
   reconstructable trail even when the listing is already offline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:56:57 +02:00
9fbe1ce728 apply: define LANGUAGE setting (fixes 500 on first handled ApplicationResult)
apply/language.py references LANGUAGE via \`from settings import *\`, but
the constant was never added to apply/settings.py. Python only errored at
the first runtime use — which is \`str(ApplicationResult)\` in the /apply
handler. Any outcome that didn't short-circuit before the final \`return
ApplyResponse(message=str(result), …)\` blew up with NameError → 500.

Add LANGUAGE = getenv("LANGUAGE", "de") so existing translations.toml
keys resolve. Reproduced live inside the apply container: NameError:
name 'LANGUAGE' is not defined at language.py:15.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:45:42 +02:00
85d73328c5 logo: +10% size (2.25rem → 2.5rem)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:38:59 +02:00
e8aa0e6e24 logo: gradient square back, dog on top; footer wording
brand-dot is back to the blue→yellow gradient rounded square, and the
didi image sits centered inside it (span wrapper, flex-centered, 88%
inner size). The dog's transparent background lets the gradient peek
through around him.

Footer: "Programmiert für Annika ♥" → "Mit ♥ programmiert für Annika".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:36:26 +02:00
d3e803beee logo: rounded square instead of circle
The image of Didi already has its own white halo, so the round crop +
extra border made it look awkward. Back to the original rounded-square
(border-radius 10px) shape and object-fit: contain so the whole dog stays
visible instead of being cropped by the circular clip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:33:53 +02:00
0c18f0870a rename to wohnungsdidi + didi logo + footer for all + seconds-only counter
- App is now called "wohnungsdidi" everywhere user-facing (page title,
  nav brand, login header, notification subjects, report filename,
  FastAPI titles, log messages)
- Brand dot replaced with an image of Didi (web/static/didi.webp),
  rendered as a round 2.25rem avatar in _layout + login
- "Programmiert für Annika ♥" footer now shows for every logged-in user,
  not only Annika
- Count-up shows only seconds ("vor 73 s") regardless of age — no
  rollover to minutes/hours
- Data continuity: DB file stays /data/lazyflat.sqlite and the Docker
  volume stays lazyflat_data so the rename doesn't strand existing data
- Session cookie renamed to wohnungsdidi_session (one-time logout on
  rollout)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:29:24 +02:00
da180bd7c7 ui batch: admin tab, time filter, count-up, chevron sync, tidy
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>
2026-04-21 17:11:58 +02:00
83db8cd902 enrichment: size floor + LLM fallback for opaque CDN URLs
Two issues surfaced on HOWOGE and similar sites:

1. Tiny icons/1x1 tracking pixels leaked through (e.g. image #5, 1.8 KB).
   Added MIN_IMAGE_BYTES = 15_000 and MIN_IMAGE_DIMENSION = 400 px on the
   short side; files below either threshold are dropped before saving.
   Pillow already gives us the dims as part of the phash pass, so the
   check is free.

2. Listings whose image URLs are opaque CDN hashes
   (.../fileadmin/_processed_/2/3/xcsm_<hash>.webp.pagespeed.ic.<hash>.webp)
   caused the LLM URL picker to reject every candidate, yielding 0 images
   for legit flats. Fixes: (a) prompt now explicitly instructs Haiku to
   keep same-host /fileadmin/_processed_/ style URLs even when the filename
   is illegible, (b) if the model still returns an empty set we fall back
   to the unfiltered Playwright candidates, trusting the pre-filter instead
   of erasing the gallery.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 16:44:47 +02:00
0aa4c6c2bb enrichment: drop LLM for structured info, dedup images by sha + phash
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>
2026-04-21 15:29:55 +02:00
374368e4af wohnungen: fall back to scraper data when LLM JSON has nulls
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>
2026-04-21 15:21:11 +02:00
a8f698bf5e enrichment: capture failure cause + admin retry button
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>
2026-04-21 15:05:39 +02:00
e0ac869425 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>
2026-04-21 14:57:11 +02:00
eb66284172 enrichment: Haiku flat details + image gallery on expand
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>
2026-04-21 14:46:12 +02:00
2609d3504a guard double-apply, hide error msg, wohnungen polish, bitwarden block
- /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>
2026-04-21 14:20:31 +02:00
de3ce19393 settings: relabel dry-run, harder PM block, rework users page
- Bewerbungen chip "Trockenmodus" → "nicht abgeschickt" (list + detail view)
- Profile form: add an off-screen honeypot (username + password) so Chrome's
  autofill burns its fill on those instead of the real E-Mail field; switch
  the visible E-Mail and Immomio-Email to type=text + inputmode=email so the
  browser heuristic no longer tags them as login emails
- Users page: create-form sits on top in its own card (3-column grid with
  Administrator checkbox inline); full-width list below with Administrator
  chip, aktiv/deaktiviert chip, "du" marker for the current user, plus
  disable/activate and a new red "löschen" button (confirm prompt) wired to
  new POST /actions/users/delete which cascades through the user's data

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 14:11:50 +02:00
931e0bb8b7 map: clickable address + status chip + Bewerben/Ablehnen in Leaflet popups
- 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>
2026-04-21 14:01:11 +02:00
7f7cbb5b1f cleanup: drop coord backfill, drop transit overlay, block PM autofill
- 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>
2026-04-21 13:55:24 +02:00
ceb2486f35 map: fixed Berlin view, CartoDB Voyager tiles, OpenRailwayMap transit overlay
- Drop the fitBounds-to-markers logic so the map always opens at the same
  Berlin-wide view (center 52.52/13.405, zoom 11), regardless of pin count
- Swap OSM standard tiles for CartoDB Voyager — cleaner, more
  Google-Maps-like base style
- Add OpenRailwayMap overlay (opacity .75) so S-/U-Bahn/Tram lines are
  highlighted on top of the base
- CSP img-src widened to cover the new tile hosts

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:48:46 +02:00
0c58242ce7 map debug + coord backfill, remove email channel, countdown label
- 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>
2026-04-21 13:42:21 +02:00
51b6b02b24 wohnungen: preserve map across HTMX polls, add rejected section, drop €/m²
- #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>
2026-04-21 13:33:55 +02:00
Moritz
4fd0b50a43 fix: lazy-init Leaflet map so tiles actually load
The map was being initialised on DOMContentLoaded even when its container
was display:none (0×0). Leaflet sets up internal bounds from container size
at init; on a zero-sized container no tiles are ever requested. Even
invalidateSize afterwards didn't recover reliably.

* map.js now only builds the Leaflet instance once the container has real
  dimensions (clientHeight >= 10). Triggered when the view toggle flips to
  Karte (rAF x2 + safety timers), and via restoreView on page load if the
  user's last choice was Karte.
* CSP img-src now includes https://unpkg.com for Leaflet marker icons.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 12:14:54 +02:00
Moritz
42377f0b67 UX: alarm-status, ablehnen-button, annika-footer, map polish
* 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>
2026-04-21 12:09:44 +02:00
Moritz
376551213a map view (Leaflet + OSM), iOS switches, Alarm → Benachrichtigungen
* 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>
2026-04-21 12:02:40 +02:00
Moritz
d9468f6814 ui: WBS dropdown, decimal-room filters, segmented toggle, 'Final absenden'
* 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>
2026-04-21 11:50:50 +02:00
Moritz
120d14e918 fix: drop spurious **kwargs on tab_logs (FastAPI parsed it as required query)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 11:43:42 +02:00
Moritz
7444f90d6a per-step screenshot + html snapshots, matches-only list, full German UI, CSV export
* 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>
2026-04-21 11:40:12 +02:00
Moritz
04b591fa9e ui: slim 4-card strip, admin-only system log, HTMX apply, title cleanup
* 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>
2026-04-21 11:25:59 +02:00
Moritz
332d9eea19 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>
2026-04-21 11:09:37 +02:00
Moritz
c630b500ef multi-user: users, per-user profiles/filters/notifications, tab UI, apply forensics
* DB: users + user_profiles/filters/notifications/preferences; applications gets
  user_id + forensics_json + profile_snapshot_json; new errors table
  with 14d retention; schema versioning via MIGRATIONS list
* auth: password hashes in DB (argon2); env vars seed first admin; per-user
  sessions; CSRF bound to user id
* apply: personal info/WBS moved out of env into the request body; providers
  take an ApplyContext with Profile + submit_forms; full Playwright recorder
  (step log, console, page errors, network, screenshots, final HTML)
* web: five top-level tabs (Wohnungen/Bewerbungen/Logs/Fehler/Einstellungen);
  settings sub-tabs profil/filter/benachrichtigungen/account/benutzer;
  per-user matching, auto-apply and notifications (UI/Telegram/SMTP); red
  auto-apply switch on Wohnungen tab; forensics detail view for bewerbungen
  and fehler; retention background thread

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:52:41 +02:00
Moritz
e663386a19 ui: switch to white/light-blue summer palette
Replace the dark theme with a light/airy look: sky-blue gradient bg,
white cards with soft blue borders, sunny gradient brand mark, pastel
status chips, and readable slate tones.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:20:39 +02:00
Moritz
27ab7dc1dc test: trigger coolify auto-deploy 2026-04-21 10:16:33 +02:00
Moritz
69f2f1f635 lazyflat: combined alert + apply behind authenticated web UI
Three isolated services (alert scraper, apply HTTP worker, web UI+DB)
with argon2 auth, signed cookies, CSRF, rate-limited login, kill switch,
apply circuit breaker, audit log, and strict CSP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:51:35 +02:00