From e3230237a20494b057f453af7b7315267d3f30e7 Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 18 Apr 2026 13:52:20 +0200 Subject: [PATCH] =?UTF-8?q?Feature:=20Gassi-Treffen=20=E2=80=94=20Orts-Aut?= =?UTF-8?q?ocomplete,=20Modal-UX,=20Teilnehmerliste,=20Karten-Fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Orts-/POI-Suche mit GPS und Vorschlägen (wie Tagebuch) + Mini-Karte im Formular - Stornieren/Austreten als Zwei-Klick-Pattern (kein UI.modal.confirm in Modals) - Teilnehmerliste im Detail-Modal mit User-Namen und Hunden - Leaflet invalidateSize auf 150ms (Memory-Regel), _loadLeaflet robuster - /api/walks/nearby Backend-Endpunkt (vor /{walk_id} Route) - SW by-v203, APP_VER 169 --- backend/routes/walks.py | 78 +++++++ backend/static/js/api.js | 13 +- backend/static/js/pages/walks.js | 361 +++++++++++++++++++++++++------ backend/static/sw.js | 2 +- 4 files changed, 379 insertions(+), 75 deletions(-) diff --git a/backend/routes/walks.py b/backend/routes/walks.py index c14064f..5210b0d 100644 --- a/backend/routes/walks.py +++ b/backend/routes/walks.py @@ -1,6 +1,7 @@ """BAN YARO — Gassi-Treffen""" import math +import httpx from datetime import date from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel @@ -20,6 +21,10 @@ def _haversine(lat1, lon1, lat2, lon2): return 2 * R * math.asin(math.sqrt(a)) +def _haversine_km(lat1, lon1, lat2, lon2): + return _haversine(lat1, lon1, lat2, lon2) / 1000 + + # ------------------------------------------------------------------ # Schemas # ------------------------------------------------------------------ @@ -103,6 +108,79 @@ async def create_walk(data: WalkCreate, user=Depends(get_current_user)): return {**dict(row), 'teilnehmer_count': 0} +# ------------------------------------------------------------------ +# GET /api/walks/nearby — POI-Suche für Treffpunkt-Autocomplete +# WICHTIG: Muss VOR /{walk_id} stehen (FastAPI Route-Reihenfolge) +# ------------------------------------------------------------------ +@router.get("/nearby") +async def nearby_places(lat: float, lon: float, user=Depends(get_current_user)): + results = [] + + with db() as conn: + # 1. User-eigene Places + places = conn.execute( + "SELECT name, typ, lat, lon FROM places WHERE lat IS NOT NULL", + ).fetchall() + for p in places: + km = _haversine_km(lat, lon, p["lat"], p["lon"]) + if km <= 5: + results.append({"name": p["name"], "type": p["typ"] or "place", + "lat": p["lat"], "lon": p["lon"], + "distance_m": int(km * 1000), "source": "places"}) + + # 2. Gecachte OSM-POIs + osm = conn.execute( + "SELECT name, type, lat, lon FROM osm_pois WHERE name IS NOT NULL AND name != ''" + ).fetchall() + for p in osm: + km = _haversine_km(lat, lon, p["lat"], p["lon"]) + if km <= 2: + results.append({"name": p["name"], "type": p["type"], + "lat": p["lat"], "lon": p["lon"], + "distance_m": int(km * 1000), "source": "osm"}) + + # 3. Overpass: benannte POIs in 1000m + try: + async with httpx.AsyncClient(timeout=6) as client: + q = ( + f'[out:json][timeout:6];' + f'(node["name"]["leisure"](around:1000,{lat},{lon});' + f' node["name"]["amenity"](around:1000,{lat},{lon});' + f' node["name"]["tourism"](around:1000,{lat},{lon});' + f' way["name"]["leisure"](around:1000,{lat},{lon});' + f');out center;' + ) + r = await client.post("https://overpass-api.de/api/interpreter", + data={"data": q}) + if r.status_code == 200: + for el in r.json().get("elements", []): + name = el.get("tags", {}).get("name") + if not name: + continue + elat = el.get("lat") or el.get("center", {}).get("lat") + elon = el.get("lon") or el.get("center", {}).get("lon") + if elat is None or elon is None: + continue + km = _haversine_km(lat, lon, elat, elon) + if km <= 1: + results.append({"name": name, "type": "osm", + "lat": elat, "lon": elon, + "distance_m": int(km * 1000), "source": "osm"}) + except Exception: + pass + + # Deduplizieren nach Name + Sortieren nach Distanz + seen = set() + unique = [] + for r in sorted(results, key=lambda x: x["distance_m"]): + key = r["name"].lower() + if key not in seen: + seen.add(key) + unique.append(r) + + return unique[:20] + + # ------------------------------------------------------------------ # GET /api/walks/{id} — Detail mit Teilnehmerliste # ------------------------------------------------------------------ diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 25021d3..6e568a5 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -223,12 +223,13 @@ const API = (() => { if (lat !== null) { params.set('lat', lat); params.set('lon', lon); } return get(`/walks?${params}`); }, - get(id) { return get(`/walks/${id}`); }, - create(data) { return post('/walks', data); }, - update(id, data) { return patch(`/walks/${id}`, data); }, - cancel(id) { return del(`/walks/${id}`); }, - join(id, dogIds) { return post(`/walks/${id}/join`, { dog_ids: dogIds }); }, - leave(id) { return del(`/walks/${id}/join`); }, + get(id) { return get(`/walks/${id}`); }, + create(data) { return post('/walks', data); }, + update(id, data) { return patch(`/walks/${id}`, data); }, + cancel(id) { return del(`/walks/${id}`); }, + join(id, dogIds) { return post(`/walks/${id}/join`, { dog_ids: dogIds }); }, + leave(id) { return del(`/walks/${id}/join`); }, + nearby(lat, lon) { return get(`/walks/nearby?lat=${lat}&lon=${lon}`); }, }; // ---------------------------------------------------------- diff --git a/backend/static/js/pages/walks.js b/backend/static/js/pages/walks.js index 6ff0e77..172513e 100644 --- a/backend/static/js/pages/walks.js +++ b/backend/static/js/pages/walks.js @@ -40,6 +40,12 @@ window.Page_walks = (() => { return iso < new Date().toISOString().slice(0, 10); } + function _sourceIcon(source) { + if (source === 'places') return 'star'; + if (source === 'osm') return 'map-pin'; + return 'map-trifold'; + } + // ---------------------------------------------------------- // INIT // ---------------------------------------------------------- @@ -112,8 +118,8 @@ window.Page_walks = (() => { if (view === 'karte') { _loadLeaflet().then(() => { _initMap(); - setTimeout(() => _map?.invalidateSize(), 50); - setTimeout(() => _map?.invalidateSize(), 300); + setTimeout(() => _map?.invalidateSize(), 150); + setTimeout(() => _map?.invalidateSize(), 400); }); } } @@ -207,17 +213,26 @@ window.Page_walks = (() => { // ---------------------------------------------------------- // Leaflet + Karte // ---------------------------------------------------------- - async function _loadLeaflet() { - if (_leafletLoaded || window.L) { _leafletLoaded = true; return; } - const link = document.createElement('link'); - link.rel = 'stylesheet'; link.href = '/css/leaflet.css'; - document.head.appendChild(link); - await new Promise(resolve => { - const s = document.createElement('script'); - s.src = '/js/leaflet.js'; s.onload = resolve; - document.head.appendChild(s); + function _loadLeaflet() { + if (window.L) { _leafletLoaded = true; return Promise.resolve(); } + return new Promise((resolve, reject) => { + const cssLoaded = document.querySelector('link[href*="leaflet"]') + ? Promise.resolve() + : new Promise(res => { + const link = document.createElement('link'); + link.rel = 'stylesheet'; link.href = '/css/leaflet.css'; + link.onload = res; link.onerror = res; + document.head.appendChild(link); + }); + cssLoaded.then(() => { + if (document.querySelector('script[src*="leaflet.js"]')) { _leafletLoaded = true; resolve(); return; } + const s = document.createElement('script'); + s.src = '/js/leaflet.js'; + s.onload = () => { _leafletLoaded = true; resolve(); }; + s.onerror = reject; + document.head.appendChild(s); + }); }); - _leafletLoaded = true; } function _initMap() { @@ -235,6 +250,7 @@ window.Page_walks = (() => { _markers.forEach(m => m.remove()); _markers = []; _data.forEach(w => { + if (!w.lat || !w.lon) return; const isFull = w.status === 'voll' || w.teilnehmer_count >= w.max_teilnehmer; const color = _isToday(w.datum) ? 'var(--c-primary)' : (isFull ? '#6B7280' : '#22C55E'); const icon = L.divIcon({ @@ -271,6 +287,7 @@ window.Page_walks = (() => { const isPast = _isPast(walk.datum); const spots = walk.max_teilnehmer - walk.teilnehmer_count; + // Teilnehmerliste const teilnehmerHTML = walk.teilnehmer?.length ? walk.teilnehmer.map(t => `
@@ -300,20 +317,35 @@ window.Page_walks = (() => { ` : ''}
- + ${teilnehmerHTML}

Veranstaltet von ${_esc(walk.veranstalter_name || 'Unbekannt')}

+ + ${isOwn && !isPast ? ` +
+ +
` : ''} + + ${isJoined && !isOwn ? ` +
+ +
` : ''} `; let footer; if (isOwn) { footer = ` - - + `; } else if (!_appState.user) { @@ -323,7 +355,6 @@ window.Page_walks = (() => { `; } else if (isJoined) { footer = ` - `; } else if (isPast || isFull) { @@ -349,13 +380,24 @@ window.Page_walks = (() => { _showEditForm(walk); }); + // Stornieren: Zwei-Klick-Pattern (kein UI.modal.confirm im Modal) + let _cancelPending = false; document.getElementById('wd-cancel-walk')?.addEventListener('click', async () => { - const ok = await UI.modal.confirm({ - title: 'Treffen stornieren?', - message: 'Alle Teilnehmer werden benachrichtigt. Nicht rückgängig.', - confirmText: 'Stornieren', danger: true, - }); - if (!ok) return; + const btn = document.getElementById('wd-cancel-walk'); + if (!_cancelPending) { + _cancelPending = true; + btn.textContent = 'Wirklich stornieren? (nochmal tippen)'; + btn.style.fontWeight = 'var(--weight-semibold)'; + setTimeout(() => { + _cancelPending = false; + if (btn) { + btn.innerHTML = `${UI.icon('x-circle')} Treffen stornieren`; + btn.style.fontWeight = ''; + } + }, 3000); + return; + } + _cancelPending = false; try { await API.walks.cancel(walk.id); _data = _data.filter(w => w.id !== walk.id); @@ -371,13 +413,24 @@ window.Page_walks = (() => { _showJoinForm(walk); }); + // Austreten: Zwei-Klick-Pattern + let _leavePending = false; document.getElementById('wd-leave')?.addEventListener('click', async () => { - const ok = await UI.modal.confirm({ - title: 'Nicht mehr teilnehmen?', - message: `Du verlässt „${walk.titel}".`, - confirmText: 'Austreten', - }); - if (!ok) return; + const btn = document.getElementById('wd-leave'); + if (!_leavePending) { + _leavePending = true; + btn.textContent = 'Wirklich austreten? (nochmal tippen)'; + btn.style.fontWeight = 'var(--weight-semibold)'; + setTimeout(() => { + _leavePending = false; + if (btn) { + btn.innerHTML = `${UI.icon('sign-out')} Nicht mehr teilnehmen`; + btn.style.fontWeight = ''; + } + }, 3000); + return; + } + _leavePending = false; try { const res = await API.walks.leave(walk.id); const idx = _data.findIndex(w => w.id === walk.id); @@ -408,23 +461,26 @@ window.Page_walks = (() => { ${_fmtDate(walk.datum)} um ${walk.uhrzeit} Uhr
${walk.ort_name ? `${UI.icon('map-pin')} ${_esc(walk.ort_name)}` : ''}

-
- - ${dogsHtml} -
+
+
+ + ${dogsHtml} +
+
`; const footer = ` - + `; UI.modal.open({ title: `Treffen beitreten`, body, footer }); document.getElementById('join-cancel')?.addEventListener('click', UI.modal.close); - document.getElementById('join-confirm')?.addEventListener('click', async () => { - const btn = document.getElementById('join-confirm'); + document.getElementById('join-form')?.addEventListener('submit', async e => { + e.preventDefault(); + const btn = document.getElementById('join-confirm'); const checked = [...document.querySelectorAll('[name="dog"]:checked')]; const dogIds = checked.map(cb => parseInt(cb.value)); @@ -441,7 +497,7 @@ window.Page_walks = (() => { } // ---------------------------------------------------------- - // Treffen erstellen + // Treffen erstellen / bearbeiten — Formular // ---------------------------------------------------------- function _showCreateForm(prefill = {}) { const today = new Date().toISOString().slice(0, 10); @@ -456,6 +512,13 @@ window.Page_walks = (() => { const isEdit = !!walk; const v = walk || defaults; + // Location-State (verwaltet außerhalb des DOM) + let _locLat = v.lat != null ? parseFloat(v.lat) : null; + let _locLon = v.lon != null ? parseFloat(v.lon) : null; + let _locName = v.ort_name || null; + + const _pinSvg = ''; + const body = `
@@ -479,20 +542,42 @@ window.Page_walks = (() => {
-
+
-
- - + + +
+
- - - - ${v.lat ? `${UI.icon('check')} Position gespeichert` : 'GPS-Button für aktuellen Standort'} - + + +
+
+
+ ${UI.icon('map-pin')} + ${_esc(_locName || '')} + +
+
+ +
+ + +
+ + + +
+ + + + +
@@ -513,36 +598,178 @@ window.Page_walks = (() => { const footer = `
`; - UI.modal.open({ title: isEdit ? 'Treffen bearbeiten' : `${UI.icon('dog')} Treffen planen`, body, footer }); + UI.modal.open({ title: isEdit ? `${UI.icon('pencil-simple')} Treffen bearbeiten` : `${UI.icon('dog')} Treffen planen`, body, footer }); document.getElementById('wf-cancel')?.addEventListener('click', UI.modal.close); - document.getElementById('walk-gps-btn')?.addEventListener('click', async () => { - const btn = document.getElementById('walk-gps-btn'); - UI.setLoading(btn, true); - try { - const pos = await API.getLocation({ enableHighAccuracy: true }); - _userPos = pos; - document.getElementById('walk-lat').value = pos.lat; - document.getElementById('walk-lon').value = pos.lon; - document.getElementById('walk-gps-hint').innerHTML = `${UI.icon('check')} Standort ermittelt`; - } catch { UI.toast.error('GPS nicht verfügbar.'); } - UI.setLoading(btn, false); + // --- Mini-Karte --- + let _miniMap = null, _miniMarker = null, _mapEditing = false; + + const _mkIcon = () => L.divIcon({ html: _pinSvg, className: '', iconSize: [28, 36], iconAnchor: [14, 36] }); + + function _placeMarker(lat, lon) { + if (_miniMarker) { _miniMarker.setLatLng([lat, lon]); return; } + _miniMarker = L.marker([lat, lon], { draggable: false, icon: _mkIcon() }).addTo(_miniMap); + _miniMarker.on('dragend', () => { + const p = _miniMarker.getLatLng(); + _locLat = p.lat; _locLon = p.lng; + document.getElementById('wf-lat').value = _locLat; + document.getElementById('wf-lon').value = _locLon; + document.getElementById('wf-location-btn-label').textContent = 'POI suchen'; + }); + } + + function _setCoords(lat, lon) { + _locLat = lat; _locLon = lon; + document.getElementById('wf-lat').value = lat; + document.getElementById('wf-lon').value = lon; + } + + function _setName(name) { + _locName = name; + document.getElementById('wf-location-label').textContent = name; + document.getElementById('wf-location-chip-wrap').style.display = ''; + document.getElementById('wf-ort-name').value = name; + document.getElementById('wf-location-suggestions').style.display = 'none'; + } + + _loadLeaflet().then(() => { + setTimeout(() => { + const lat = _locLat || 48.0, lon = _locLon || 11.9, zoom = _locLat ? 15 : 7; + _miniMap = L.map('wf-map-wrap', { + zoomControl: true, attributionControl: false, + dragging: true, scrollWheelZoom: false, + }).setView([lat, lon], zoom); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }) + .addTo(_miniMap); + _miniMap.invalidateSize(); + if (_locLat) { + _placeMarker(lat, lon); + _miniMarker.dragging.disable(); + } + _miniMap.on('click', e => { + if (!_mapEditing) return; + _setCoords(e.latlng.lat, e.latlng.lng); + _placeMarker(_locLat, _locLon); + document.getElementById('wf-location-btn-label').textContent = 'POI suchen'; + }); + }, 150); }); + // Ort-Name-Chip entfernen + document.getElementById('wf-location-clear')?.addEventListener('click', () => { + _locName = null; + document.getElementById('wf-location-chip-wrap').style.display = 'none'; + document.getElementById('wf-ort-name').value = ''; + }); + + // Koordinaten + Name entfernen (Zwei-Klick) + const clearBtn = document.getElementById('wf-coords-clear'); + let _clearPending = false; + clearBtn?.addEventListener('click', () => { + if (!_clearPending) { + _clearPending = true; + clearBtn.textContent = 'Wirklich entfernen?'; + clearBtn.style.color = 'var(--c-danger)'; + setTimeout(() => { + _clearPending = false; + if (clearBtn) { + clearBtn.textContent = 'Ort entfernen'; + clearBtn.style.color = ''; + } + }, 3000); + return; + } + _clearPending = false; + clearBtn.textContent = 'Ort entfernen'; + clearBtn.style.color = ''; + _locLat = null; _locLon = null; _locName = null; + document.getElementById('wf-lat').value = ''; + document.getElementById('wf-lon').value = ''; + document.getElementById('wf-ort-name').value = ''; + document.getElementById('wf-location-chip-wrap').style.display = 'none'; + document.getElementById('wf-location-suggestions').style.display = 'none'; + document.getElementById('wf-location-btn-label').textContent = 'GPS → POI suchen'; + if (_miniMarker) { _miniMarker.remove(); _miniMarker = null; } + if (_miniMap) { _miniMap.setView([48.0, 11.9], 7); } + }); + + // GPS → POI-Suche (wie diary.js) + async function _showSuggestions() { + const btn = document.getElementById('wf-location-btn'); + UI.setLoading(btn, true); + try { + let lat = _locLat, lon = _locLon; + if (lat == null || lon == null) { + const pos = await API.getLocation({ enableHighAccuracy: true }); + lat = pos.lat; lon = pos.lon; + _setCoords(lat, lon); + if (_miniMap) { + _miniMap.setView([lat, lon], 15); + _placeMarker(lat, lon); + if (_miniMarker) _miniMarker.dragging.disable(); + } + document.getElementById('wf-location-btn-label').textContent = 'POI suchen'; + } + + const suggestions = _appState.user + ? await API.walks.nearby(lat, lon) + : []; + + const sugEl = document.getElementById('wf-location-suggestions'); + if (!suggestions.length) { + sugEl.innerHTML = '

Keine Orte in der Nähe gefunden.

'; + } else { + sugEl.innerHTML = suggestions.map(s => ` + `).join(''); + sugEl.querySelectorAll('.diary-location-suggestion').forEach(el => { + el.addEventListener('click', () => { + const slat = parseFloat(el.dataset.lat); + const slon = parseFloat(el.dataset.lon); + _setCoords(slat, slon); + _setName(el.dataset.name); + if (_miniMap) { + _miniMap.setView([slat, slon], 16); + _placeMarker(slat, slon); + if (_miniMarker) _miniMarker.dragging.disable(); + } + }); + }); + } + sugEl.style.display = ''; + } catch (err) { + UI.toast.error(err?.message?.includes('GPS') || _locLat == null + ? 'GPS nicht verfügbar.' : 'Ortssuche fehlgeschlagen.'); + } finally { + UI.setLoading(btn, false); + } + } + + document.getElementById('wf-location-btn')?.addEventListener('click', _showSuggestions); + + // Formular absenden document.getElementById('walk-form')?.addEventListener('submit', async e => { e.preventDefault(); const btn = document.querySelector('[form="walk-form"][type="submit"]') || e.target.querySelector('[type="submit"]'); const fd = UI.formData(e.target); - if (!fd.lat || !fd.lon) { - UI.toast.warning('Bitte GPS-Position ermitteln.'); + // Koordinaten aus State lesen (nicht aus fd, da hidden) + const lat = _locLat; + const lon = _locLon; + + if (!lat || !lon) { + UI.toast.warning('Bitte einen Treffpunkt auf der Karte wählen oder GPS nutzen.'); return; } @@ -551,9 +778,9 @@ window.Page_walks = (() => { titel: fd.titel?.trim(), datum: fd.datum, uhrzeit: fd.uhrzeit, - lat: parseFloat(fd.lat), - lon: parseFloat(fd.lon), - ort_name: fd.ort_name || null, + lat: parseFloat(lat), + lon: parseFloat(lon), + ort_name: _locName || null, max_teilnehmer: parseInt(fd.max_teilnehmer) || 10, beschreibung: fd.beschreibung || null, }; @@ -566,8 +793,6 @@ window.Page_walks = (() => { } else { const created = await API.walks.create(payload); _data.unshift({ ...created, teilnehmer_count: 0 }); - // Beim eigenen neuen Treffen gleich beitreten? - // Nein — Veranstalter ist automatisch dabei (für Teilnehmer-Sicht) UI.toast.success('Treffen geplant! 🎉'); } diff --git a/backend/static/sw.js b/backend/static/sw.js index 770545e..64d26b1 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v202'; +const CACHE_VERSION = 'by-v203'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten