From ec17dfb0291a6d5d5edddb1ab5961246635cca0c Mon Sep 17 00:00:00 2001 From: rene Date: Tue, 14 Apr 2026 06:12:52 +0200 Subject: [PATCH] =?UTF-8?q?Sprint=207:=20Gassi-Treffen=20=E2=80=94=20Meetu?= =?UTF-8?q?p-Feature=20komplett?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: walks.py mit allen Endpoints (CRUD, join/leave, Haversine-Filter) - DB: walks, walk_participants, walk_participant_dogs Tabellen (bereits in database.py) - Frontend: walks.js — Liste/Karte-Toggle, Heute/Demnächst-Gruppierung, Detail-Modal mit Teilnehmerliste, Beitreten/Verlassen, Erstellen/Bearbeiten-Formulare - CSS: Walks-Komponenten (Card, Date-Badge, Spots-Anzeige, Map-View) - api.js: walks-Abschnitt (list, get, create, update, cancel, join, leave) - SW-Cache: by-v20 → by-v21 --- backend/main.py | 2 + backend/routes/walks.py | 255 +++++++++++++ backend/static/css/components.css | 121 +++++++ backend/static/js/api.js | 19 +- backend/static/js/pages/walks.js | 580 ++++++++++++++++++++++++++++++ backend/static/sw.js | 2 +- 6 files changed, 977 insertions(+), 2 deletions(-) create mode 100644 backend/routes/walks.py create mode 100644 backend/static/js/pages/walks.js diff --git a/backend/main.py b/backend/main.py index 9fc945f..d7dc7d6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -59,6 +59,7 @@ from routes.ki import router as ki_router from routes.tieraerzte import router as tieraerzte_router from routes.places import router as places_router from routes.routen import router as routen_router +from routes.walks import router as walks_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -70,6 +71,7 @@ app.include_router(ki_router, prefix="/api/ki", tags=["KI"]) app.include_router(tieraerzte_router, prefix="/api/tieraerzte", tags=["Tierärzte"]) app.include_router(places_router, prefix="/api/places", tags=["Orte"]) app.include_router(routen_router, prefix="/api/routes", tags=["Routen"]) +app.include_router(walks_router, prefix="/api/walks", tags=["Gassi-Treffen"]) # ------------------------------------------------------------------ diff --git a/backend/routes/walks.py b/backend/routes/walks.py new file mode 100644 index 0000000..c14064f --- /dev/null +++ b/backend/routes/walks.py @@ -0,0 +1,255 @@ +"""BAN YARO — Gassi-Treffen""" + +import math +from datetime import date +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import Optional, List +from database import db +from auth import get_current_user + +router = APIRouter() + + +def _haversine(lat1, lon1, lat2, lon2): + R = 6_371_000 + p1, p2 = math.radians(lat1), math.radians(lat2) + dp = math.radians(lat2 - lat1) + dl = math.radians(lon2 - lon1) + a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 + return 2 * R * math.asin(math.sqrt(a)) + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ +class WalkCreate(BaseModel): + titel: str + datum: str # YYYY-MM-DD + uhrzeit: str # HH:MM + lat: float + lon: float + ort_name: Optional[str] = None + max_teilnehmer: int = 10 + beschreibung: Optional[str] = None + +class WalkUpdate(BaseModel): + titel: Optional[str] = None + datum: Optional[str] = None + uhrzeit: Optional[str] = None + lat: Optional[float] = None + lon: Optional[float] = None + ort_name: Optional[str] = None + max_teilnehmer: Optional[int] = None + beschreibung: Optional[str] = None + +class JoinRequest(BaseModel): + dog_ids: List[int] = [] # leere Liste = ohne Hund (selten) + + +# ------------------------------------------------------------------ +# GET /api/walks — alle offenen Treffen (ab heute, optional Umkreis) +# ------------------------------------------------------------------ +@router.get("") +async def list_walks( + lat: Optional[float] = None, + lon: Optional[float] = None, + radius: int = 20000, + alle: bool = False, # True → auch vergangene / stornierte +): + today = date.today().isoformat() + with db() as conn: + q = """ + SELECT w.*, + u.name AS veranstalter_name, + COUNT(DISTINCT wp.user_id) AS teilnehmer_count + FROM walks w + LEFT JOIN users u ON u.id = w.user_id + LEFT JOIN walk_participants wp ON wp.walk_id = w.id + WHERE w.status != 'storniert' + """ + if not alle: + q += f" AND w.datum >= '{today}'" + q += " GROUP BY w.id ORDER BY w.datum ASC, w.uhrzeit ASC" + rows = conn.execute(q).fetchall() + + result = [dict(r) for r in rows] + + # Umkreis-Filter + if lat is not None and lon is not None: + result = [r for r in result if _haversine(lat, lon, r['lat'], r['lon']) <= radius] + + return result + + +# ------------------------------------------------------------------ +# POST /api/walks — Treffen erstellen +# ------------------------------------------------------------------ +@router.post("", status_code=201) +async def create_walk(data: WalkCreate, user=Depends(get_current_user)): + with db() as conn: + cur = conn.execute(""" + INSERT INTO walks (user_id, titel, datum, uhrzeit, lat, lon, + ort_name, max_teilnehmer, beschreibung) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (user['id'], data.titel, data.datum, data.uhrzeit, + data.lat, data.lon, data.ort_name, + data.max_teilnehmer, data.beschreibung)) + row = conn.execute( + "SELECT w.*, u.name AS veranstalter_name FROM walks w " + "LEFT JOIN users u ON u.id = w.user_id WHERE w.id = ?", + (cur.lastrowid,) + ).fetchone() + return {**dict(row), 'teilnehmer_count': 0} + + +# ------------------------------------------------------------------ +# GET /api/walks/{id} — Detail mit Teilnehmerliste +# ------------------------------------------------------------------ +@router.get("/{walk_id}") +async def get_walk(walk_id: int): + with db() as conn: + walk = conn.execute( + "SELECT w.*, u.name AS veranstalter_name FROM walks w " + "LEFT JOIN users u ON u.id = w.user_id WHERE w.id = ?", + (walk_id,) + ).fetchone() + if not walk: + raise HTTPException(404, "Treffen nicht gefunden.") + + # Teilnehmer mit Hunden + participants = conn.execute(""" + SELECT wp.user_id, u.name AS user_name, + GROUP_CONCAT(d.name, ', ') AS hunde + FROM walk_participants wp + JOIN users u ON u.id = wp.user_id + LEFT JOIN walk_participant_dogs wpd + ON wpd.walk_id = wp.walk_id AND wpd.user_id = wp.user_id + LEFT JOIN dogs d ON d.id = wpd.dog_id + WHERE wp.walk_id = ? + GROUP BY wp.user_id + """, (walk_id,)).fetchall() + + result = dict(walk) + result['teilnehmer'] = [dict(p) for p in participants] + result['teilnehmer_count'] = len(result['teilnehmer']) + return result + + +# ------------------------------------------------------------------ +# PATCH /api/walks/{id} +# ------------------------------------------------------------------ +@router.patch("/{walk_id}") +async def update_walk(walk_id: int, data: WalkUpdate, user=Depends(get_current_user)): + with db() as conn: + walk = conn.execute("SELECT * FROM walks WHERE id = ?", (walk_id,)).fetchone() + if not walk: + raise HTTPException(404, "Treffen nicht gefunden.") + if walk['user_id'] != user['id']: + raise HTTPException(403, "Nur der Veranstalter kann das Treffen bearbeiten.") + + updates = data.model_dump(exclude_none=True) + if updates: + cols = ', '.join(f"{k} = ?" for k in updates) + conn.execute(f"UPDATE walks SET {cols} WHERE id = ?", [*updates.values(), walk_id]) + + row = conn.execute( + "SELECT w.*, u.name AS veranstalter_name FROM walks w " + "LEFT JOIN users u ON u.id = w.user_id WHERE w.id = ?", + (walk_id,) + ).fetchone() + count = conn.execute( + "SELECT COUNT(*) FROM walk_participants WHERE walk_id = ?", (walk_id,) + ).fetchone()[0] + return {**dict(row), 'teilnehmer_count': count} + + +# ------------------------------------------------------------------ +# DELETE /api/walks/{id} — stornieren +# ------------------------------------------------------------------ +@router.delete("/{walk_id}", status_code=204) +async def cancel_walk(walk_id: int, user=Depends(get_current_user)): + with db() as conn: + walk = conn.execute("SELECT * FROM walks WHERE id = ?", (walk_id,)).fetchone() + if not walk: + raise HTTPException(404, "Treffen nicht gefunden.") + if walk['user_id'] != user['id']: + raise HTTPException(403, "Nur der Veranstalter kann das Treffen stornieren.") + conn.execute("UPDATE walks SET status = 'storniert' WHERE id = ?", (walk_id,)) + + +# ------------------------------------------------------------------ +# POST /api/walks/{id}/join — beitreten +# ------------------------------------------------------------------ +@router.post("/{walk_id}/join") +async def join_walk(walk_id: int, data: JoinRequest, user=Depends(get_current_user)): + with db() as conn: + walk = conn.execute("SELECT * FROM walks WHERE id = ?", (walk_id,)).fetchone() + if not walk: + raise HTTPException(404, "Treffen nicht gefunden.") + if walk['status'] != 'offen': + raise HTTPException(400, "Dieses Treffen ist nicht mehr offen.") + + # Bereits beigetreten? + existing = conn.execute( + "SELECT 1 FROM walk_participants WHERE walk_id = ? AND user_id = ?", + (walk_id, user['id']) + ).fetchone() + if existing: + raise HTTPException(409, "Du nimmst bereits teil.") + + # Platz frei? + count = conn.execute( + "SELECT COUNT(*) FROM walk_participants WHERE walk_id = ?", (walk_id,) + ).fetchone()[0] + if count >= walk['max_teilnehmer']: + raise HTTPException(400, "Das Treffen ist bereits voll.") + + # Beitreten + primary_dog = data.dog_ids[0] if data.dog_ids else None + conn.execute( + "INSERT INTO walk_participants (walk_id, user_id, dog_id) VALUES (?, ?, ?)", + (walk_id, user['id'], primary_dog) + ) + # Hunde eintragen + for dog_id in data.dog_ids: + conn.execute( + "INSERT OR IGNORE INTO walk_participant_dogs (walk_id, user_id, dog_id) VALUES (?, ?, ?)", + (walk_id, user['id'], dog_id) + ) + + new_count = count + 1 + if new_count >= walk['max_teilnehmer']: + conn.execute("UPDATE walks SET status = 'voll' WHERE id = ?", (walk_id,)) + + return {"status": "joined", "teilnehmer_count": new_count} + + +# ------------------------------------------------------------------ +# DELETE /api/walks/{id}/join — verlassen +# ------------------------------------------------------------------ +@router.delete("/{walk_id}/join", status_code=200) +async def leave_walk(walk_id: int, user=Depends(get_current_user)): + with db() as conn: + walk = conn.execute("SELECT * FROM walks WHERE id = ?", (walk_id,)).fetchone() + if not walk: + raise HTTPException(404, "Treffen nicht gefunden.") + if walk['user_id'] == user['id']: + raise HTTPException(400, "Als Veranstalter kannst du nicht austreten — storniere das Treffen stattdessen.") + + conn.execute( + "DELETE FROM walk_participants WHERE walk_id = ? AND user_id = ?", + (walk_id, user['id']) + ) + conn.execute( + "DELETE FROM walk_participant_dogs WHERE walk_id = ? AND user_id = ?", + (walk_id, user['id']) + ) + # Status ggf. wieder auf offen setzen + count = conn.execute( + "SELECT COUNT(*) FROM walk_participants WHERE walk_id = ?", (walk_id,) + ).fetchone()[0] + if walk['status'] == 'voll': + conn.execute("UPDATE walks SET status = 'offen' WHERE id = ?", (walk_id,)) + + return {"status": "left", "teilnehmer_count": count} diff --git a/backend/static/css/components.css b/backend/static/css/components.css index fa591c7..123905b 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -1619,3 +1619,124 @@ textarea.form-control { align-items: center; justify-content: center; } + +/* ------------------------------------------------------------ + GASSI-TREFFEN (walks.js) + ------------------------------------------------------------ */ +.walks-toolbar { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + background: var(--c-surface); + border-bottom: 1px solid var(--c-border); + flex-shrink: 0; +} +.walks-view-toggle { + display: flex; + gap: var(--space-1); + background: var(--c-bg); + border-radius: var(--radius-md); + padding: 2px; + border: 1px solid var(--c-border); +} +.walks-view-btn { + padding: var(--space-1) var(--space-3); + border-radius: var(--radius-sm); + border: none; + background: transparent; + color: var(--c-text-secondary); + font-size: var(--text-sm); + cursor: pointer; + transition: all 0.15s; +} +.walks-view-btn.active { + background: var(--c-surface); + color: var(--c-text); + box-shadow: var(--shadow-xs); +} +.walks-list { + flex: 1; + overflow-y: auto; + padding: var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-3); +} +.walks-section-label { + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + color: var(--c-text-secondary); + padding: var(--space-1) 0; + margin-bottom: var(--space-1); +} +.walks-card { + background: var(--c-surface); + border-radius: var(--radius-lg); + border: 1px solid var(--c-border); + padding: var(--space-4); + display: grid; + grid-template-columns: 56px 1fr auto; + gap: var(--space-3); + cursor: pointer; + transition: box-shadow 0.15s; + box-shadow: var(--shadow-xs); +} +.walks-card:hover { box-shadow: var(--shadow-md); } +.walks-card.today { border-left: 3px solid var(--c-amber, #f59e0b); } +.walks-card.full { opacity: 0.6; } +.walks-date-badge { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: var(--c-bg); + border-radius: var(--radius-md); + padding: var(--space-1) var(--space-2); + min-width: 52px; +} +.walks-date-badge .day { font-size: var(--text-xs); color: var(--c-text-secondary); } +.walks-date-badge .num { font-size: 1.5rem; font-weight: var(--weight-bold); line-height: 1.1; } +.walks-date-badge .month { font-size: var(--text-xs); color: var(--c-text-secondary); } +.walks-date-badge.today-badge .num { color: var(--c-amber, #f59e0b); } +.walks-card-body { min-width: 0; } +.walks-card-title { + font-weight: var(--weight-semibold); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.walks-card-meta { + font-size: var(--text-sm); + color: var(--c-text-secondary); + margin-top: var(--space-1); +} +.walks-card-side { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: var(--space-1); +} +.walks-spots { + font-size: var(--text-xs); + font-weight: var(--weight-semibold); + color: var(--c-success); + white-space: nowrap; +} +.walks-spots.full { color: var(--c-text-muted); } +.walks-spots.today { color: var(--c-amber, #f59e0b); } +.walks-map { + flex: 1; + position: relative; +} +.walks-participant { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) 0; + border-bottom: 1px solid var(--c-border); + font-size: var(--text-sm); +} +.walks-participant:last-child { border-bottom: none; } +.walks-participant-name { font-weight: var(--weight-semibold); } +.walks-participant-dogs { color: var(--c-text-secondary); } diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 9cbd305..705b826 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -190,6 +190,23 @@ const API = (() => { delete(id) { return del(`/routes/${id}`); }, }; + // ---------------------------------------------------------- + // GASSI-TREFFEN + // ---------------------------------------------------------- + const walks = { + list(lat = null, lon = null, radius = 20000) { + const params = new URLSearchParams({ radius }); + 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`); }, + }; + // ---------------------------------------------------------- // WETTER // ---------------------------------------------------------- @@ -267,7 +284,7 @@ const API = (() => { return { get, post, put, patch, del, upload, auth, dogs, diary, health, tieraerzte, poison, - places, routes, weather, push, + places, routes, walks, weather, push, subscribeToPush, getLocation, APIError, }; diff --git a/backend/static/js/pages/walks.js b/backend/static/js/pages/walks.js new file mode 100644 index 0000000..968f26a --- /dev/null +++ b/backend/static/js/pages/walks.js @@ -0,0 +1,580 @@ +/* ============================================================ + BAN YARO — Gassi-Treffen + Treffen entdecken, erstellen, beitreten + ============================================================ */ + +window.Page_walks = (() => { + + let _container = null; + let _appState = null; + let _data = []; + let _view = 'liste'; // 'liste' | 'karte' + let _map = null; + let _markers = []; + let _leafletLoaded = false; + let _userPos = null; + + function _esc(s) { + return String(s || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + } + + // Datum deutsch formatieren: "2026-04-20" → "Sonntag, 20. April 2026" + function _fmtDate(iso) { + if (!iso) return '—'; + const d = new Date(iso + 'T12:00:00'); + return d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); + } + + // Datum kurz: "So, 20.04." + function _fmtDateShort(iso) { + if (!iso) return '—'; + const d = new Date(iso + 'T12:00:00'); + return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' }); + } + + function _isToday(iso) { + return iso === new Date().toISOString().slice(0, 10); + } + + function _isPast(iso) { + return iso < new Date().toISOString().slice(0, 10); + } + + // ---------------------------------------------------------- + // INIT + // ---------------------------------------------------------- + async function init(container, appState) { + _container = container; + _appState = appState; + _render(); + try { _userPos = await API.getLocation(); } catch {} + _loadData(); + } + + function refresh() { _loadData(); } + function onDogChange() {} + function openNew() { _showCreateForm(); } + + // ---------------------------------------------------------- + // RENDER — Grundstruktur + // ---------------------------------------------------------- + function _render() { + _container.innerHTML = ` +
+ + +
+
+ + +
+ +
+ + +
+
+

Lädt…

+
+
+ + + + +
+ `; + + document.getElementById('walks-view-toggle').addEventListener('click', e => { + const btn = e.target.closest('.walks-view-btn'); + if (!btn) return; + _switchView(btn.dataset.view); + }); + + document.getElementById('walks-create-btn').addEventListener('click', () => { + if (!_appState.user) { + UI.toast.warning('Bitte zuerst anmelden.'); + App.navigate('settings'); + return; + } + _showCreateForm(); + }); + } + + function _switchView(view) { + _view = view; + document.querySelectorAll('.walks-view-btn').forEach(b => + b.classList.toggle('active', b.dataset.view === view)); + document.getElementById('walks-list-view').style.display = view === 'liste' ? '' : 'none'; + document.getElementById('walks-map-view').style.display = view === 'karte' ? '' : 'none'; + + if (view === 'karte') { + _loadLeaflet().then(() => { + _initMap(); + setTimeout(() => _map?.invalidateSize(), 50); + }); + } + } + + // ---------------------------------------------------------- + // Daten laden + // ---------------------------------------------------------- + async function _loadData() { + try { + _data = await API.walks.list( + _userPos?.lat ?? null, + _userPos?.lon ?? null + ); + _renderList(); + _renderMarkers(); + } catch (err) { + UI.toast.error(err.message || 'Fehler beim Laden.'); + } + } + + // ---------------------------------------------------------- + // Liste rendern + // ---------------------------------------------------------- + function _renderList() { + const el = document.getElementById('walks-list'); + if (!el) return; + + if (!_data.length) { + el.innerHTML = ` +
+
🐕
+

Noch keine Treffen in deiner Nähe.

+ +
`; + document.getElementById('walks-first-btn')?.addEventListener('click', _showCreateForm); + return; + } + + // Heute + zukünftige Treffen + const heute = _data.filter(w => _isToday(w.datum)); + const upcoming = _data.filter(w => !_isToday(w.datum) && !_isPast(w.datum)); + + let html = ''; + + if (heute.length) { + html += `
🌟 Heute
`; + html += heute.map(w => _walkCardHTML(w)).join(''); + } + + if (upcoming.length) { + html += `
📅 Demnächst
`; + html += upcoming.map(w => _walkCardHTML(w)).join(''); + } + + el.innerHTML = `
${html}
`; + + el.querySelectorAll('.walks-card').forEach(card => { + card.addEventListener('click', () => _openDetail(parseInt(card.dataset.id))); + }); + } + + function _walkCardHTML(w) { + const isOwn = _appState.user?.id === w.user_id; + const isFull = w.status === 'voll' || w.teilnehmer_count >= w.max_teilnehmer; + const today = _isToday(w.datum); + const spots = w.max_teilnehmer - w.teilnehmer_count; + + return ` +
+
+
${_fmtDateShort(w.datum)}
+
${w.uhrzeit}
+
+
+
${_esc(w.titel)}
+ ${w.ort_name ? `
📍 ${_esc(w.ort_name)}
` : ''} +
+ + ${isFull ? '🔴 Voll' : `🟢 ${spots} Platz${spots !== 1 ? 'e' : ''} frei`} + + 🐾 ${w.teilnehmer_count}/${w.max_teilnehmer} + ${isOwn ? 'Mein Treffen' : ''} +
+
+
+
`; + } + + // ---------------------------------------------------------- + // 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); + }); + _leafletLoaded = true; + } + + function _initMap() { + const el = document.getElementById('walks-map'); + if (!el || !window.L || _map) return; + const center = _userPos ? [_userPos.lat, _userPos.lon] : [51.1657, 10.4515]; + _map = L.map('walks-map', { zoomControl: true, attributionControl: false }) + .setView(center, _userPos ? 12 : 6); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_map); + _renderMarkers(); + } + + function _renderMarkers() { + if (!_map || !window.L) return; + _markers.forEach(m => m.remove()); + _markers = []; + _data.forEach(w => { + const isFull = w.status === 'voll' || w.teilnehmer_count >= w.max_teilnehmer; + const color = _isToday(w.datum) ? '#C4843A' : (isFull ? '#6B7280' : '#22C55E'); + const icon = L.divIcon({ + className: '', + html: `
🐕
`, + iconSize: [32, 32], iconAnchor: [16, 16], + }); + const m = L.marker([w.lat, w.lon], { icon }) + .addTo(_map) + .bindTooltip(`${w.titel} · ${_fmtDateShort(w.datum)} ${w.uhrzeit}`, { direction: 'top', offset: [0,-16] }) + .on('click', () => _openDetail(w.id)); + _markers.push(m); + }); + } + + // ---------------------------------------------------------- + // Detail-Modal + // ---------------------------------------------------------- + async function _openDetail(walkId) { + let walk; + try { + walk = await API.walks.get(walkId); + } catch (err) { + UI.toast.error(err.message); + return; + } + + const isOwn = _appState.user?.id === walk.user_id; + const isJoined = walk.teilnehmer?.some(t => t.user_id === _appState.user?.id); + const isFull = walk.status === 'voll' || walk.teilnehmer_count >= walk.max_teilnehmer; + const isPast = _isPast(walk.datum); + const spots = walk.max_teilnehmer - walk.teilnehmer_count; + + const teilnehmerHTML = walk.teilnehmer?.length + ? walk.teilnehmer.map(t => ` +
+ 🧑 ${_esc(t.user_name)} + ${t.hunde ? `🐕 ${_esc(t.hunde)}` : ''} +
`).join('') + : `

Noch keine Teilnehmer.

`; + + const body = ` +
+
+ ${_fmtDate(walk.datum)}
+ um ${walk.uhrzeit} Uhr +
+ ${walk.ort_name ? `
📍 ${_esc(walk.ort_name)}
` : ''} +
+ + ${isFull ? '🔴 Voll' : `🟢 ${spots} Platz${spots !== 1 ? 'e' : ''} frei`} + + 🐾 ${walk.teilnehmer_count}/${walk.max_teilnehmer} Teilnehmer + ${isOwn ? 'Dein Treffen' : ''} +
+
+ + ${walk.beschreibung ? ` +

${_esc(walk.beschreibung)}

+ ` : ''} + +
+ + ${teilnehmerHTML} +
+ +

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

+ `; + + let footer; + if (isOwn) { + footer = ` + + + + `; + } else if (!_appState.user) { + footer = ` + + + `; + } else if (isJoined) { + footer = ` + + + `; + } else if (isPast || isFull) { + footer = ``; + } else { + footer = ` + + + `; + } + + UI.modal.open({ title: `🐕 ${walk.titel}`, body, footer }); + + document.getElementById('wd-close')?.addEventListener('click', UI.modal.close); + + document.getElementById('wd-login')?.addEventListener('click', () => { + UI.modal.close(); + App.navigate('settings'); + }); + + document.getElementById('wd-edit')?.addEventListener('click', () => { + UI.modal.close(); + _showEditForm(walk); + }); + + 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; + try { + await API.walks.cancel(walk.id); + _data = _data.filter(w => w.id !== walk.id); + UI.modal.close(); + _renderList(); + _renderMarkers(); + UI.toast.success('Treffen storniert.'); + } catch (err) { UI.toast.error(err.message); } + }); + + document.getElementById('wd-join')?.addEventListener('click', () => { + UI.modal.close(); + _showJoinForm(walk); + }); + + 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; + try { + const res = await API.walks.leave(walk.id); + const idx = _data.findIndex(w => w.id === walk.id); + if (idx !== -1) _data[idx].teilnehmer_count = res.teilnehmer_count; + UI.modal.close(); + _renderList(); + UI.toast.success('Du nimmst nicht mehr teil.'); + } catch (err) { UI.toast.error(err.message); } + }); + } + + // ---------------------------------------------------------- + // Beitreten-Formular (Hunde wählen) + // ---------------------------------------------------------- + function _showJoinForm(walk) { + const dogs = _appState.dogs || []; + const dogsHtml = dogs.length + ? dogs.map(d => ` + `).join('') + : `

Keine Hunde im Profil — du kannst trotzdem mitmachen.

`; + + const body = ` +

+ ${_fmtDate(walk.datum)} um ${walk.uhrzeit} Uhr
+ ${walk.ort_name ? `📍 ${_esc(walk.ort_name)}` : ''} +

+
+ + ${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'); + const checked = [...document.querySelectorAll('[name="dog"]:checked')]; + const dogIds = checked.map(cb => parseInt(cb.value)); + + await UI.asyncButton(btn, async () => { + const res = await API.walks.join(walk.id, dogIds); + const idx = _data.findIndex(w => w.id === walk.id); + if (idx !== -1) _data[idx].teilnehmer_count = res.teilnehmer_count; + UI.modal.close(); + _renderList(); + _renderMarkers(); + UI.toast.success(`Du nimmst teil! 🎉`); + }); + }); + } + + // ---------------------------------------------------------- + // Treffen erstellen + // ---------------------------------------------------------- + function _showCreateForm(prefill = {}) { + const today = new Date().toISOString().slice(0, 10); + _showWalkForm(null, { datum: today, uhrzeit: '10:00', ...prefill }); + } + + function _showEditForm(walk) { + _showWalkForm(walk); + } + + function _showWalkForm(walk, defaults = {}) { + const isEdit = !!walk; + const v = walk || defaults; + + const body = ` +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+ + +
+ + + + ${v.lat ? '✅ Position gespeichert' : 'GPS-Button für aktuellen Standort'} + +
+ +
+ + +
+ +
+ + +
+ +
+ `; + + const footer = ` + + + `; + + UI.modal.open({ title: isEdit ? 'Treffen bearbeiten' : '🐕 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').textContent = '✅ Standort ermittelt'; + } catch { UI.toast.error('GPS nicht verfügbar.'); } + UI.setLoading(btn, false); + }); + + 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 (📍).'); + return; + } + + await UI.asyncButton(btn, async () => { + const payload = { + titel: fd.titel?.trim(), + datum: fd.datum, + uhrzeit: fd.uhrzeit, + lat: parseFloat(fd.lat), + lon: parseFloat(fd.lon), + ort_name: fd.ort_name || null, + max_teilnehmer: parseInt(fd.max_teilnehmer) || 10, + beschreibung: fd.beschreibung || null, + }; + + if (isEdit) { + const updated = await API.walks.update(walk.id, payload); + const idx = _data.findIndex(w => w.id === walk.id); + if (idx !== -1) _data[idx] = { ..._data[idx], ...updated }; + UI.toast.success('Treffen aktualisiert.'); + } 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! 🎉'); + } + + UI.modal.close(); + _renderList(); + _renderMarkers(); + }); + }); + } + + return { init, refresh, onDogChange, openNew }; + +})(); diff --git a/backend/static/sw.js b/backend/static/sw.js index 983ee26..7c891be 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications ============================================================ */ -const CACHE_VERSION = 'by-v20'; +const CACHE_VERSION = 'by-v21'; const CACHE_STATIC = `${CACHE_VERSION}-static`; // Diese Dateien werden beim Install gecacht (App Shell)