Sprint 7: Gassi-Treffen — Meetup-Feature komplett

- 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
This commit is contained in:
rene 2026-04-14 06:12:52 +02:00
parent b9df636535
commit ec17dfb029
6 changed files with 977 additions and 2 deletions

View file

@ -59,6 +59,7 @@ from routes.ki import router as ki_router
from routes.tieraerzte import router as tieraerzte_router from routes.tieraerzte import router as tieraerzte_router
from routes.places import router as places_router from routes.places import router as places_router
from routes.routen import router as routen_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(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) 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(tieraerzte_router, prefix="/api/tieraerzte", tags=["Tierärzte"])
app.include_router(places_router, prefix="/api/places", tags=["Orte"]) app.include_router(places_router, prefix="/api/places", tags=["Orte"])
app.include_router(routen_router, prefix="/api/routes", tags=["Routen"]) app.include_router(routen_router, prefix="/api/routes", tags=["Routen"])
app.include_router(walks_router, prefix="/api/walks", tags=["Gassi-Treffen"])
# ------------------------------------------------------------------ # ------------------------------------------------------------------

255
backend/routes/walks.py Normal file
View file

@ -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}

View file

@ -1619,3 +1619,124 @@ textarea.form-control {
align-items: center; align-items: center;
justify-content: 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); }

View file

@ -190,6 +190,23 @@ const API = (() => {
delete(id) { return del(`/routes/${id}`); }, 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 // WETTER
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -267,7 +284,7 @@ const API = (() => {
return { return {
get, post, put, patch, del, upload, get, post, put, patch, del, upload,
auth, dogs, diary, health, tieraerzte, poison, auth, dogs, diary, health, tieraerzte, poison,
places, routes, weather, push, places, routes, walks, weather, push,
subscribeToPush, getLocation, subscribeToPush, getLocation,
APIError, APIError,
}; };

View file

@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// 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 = `
<div class="walks-layout">
<!-- Toolbar -->
<div class="walks-toolbar">
<div class="walks-view-toggle" id="walks-view-toggle">
<button class="walks-view-btn active" data-view="liste">📋 Liste</button>
<button class="walks-view-btn" data-view="karte">🗺 Karte</button>
</div>
<button class="btn btn-primary btn-sm" id="walks-create-btn">+ Treffen planen</button>
</div>
<!-- Liste -->
<div id="walks-list-view" class="walks-content">
<div id="walks-list">
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt</p>
</div>
</div>
<!-- Karte -->
<div id="walks-map-view" class="walks-content" style="display:none">
<div id="walks-map" class="walks-map"></div>
</div>
</div>
`;
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 = `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="font-size:3rem;margin-bottom:var(--space-3)">🐕</div>
<p style="color:var(--c-text-secondary)">Noch keine Treffen in deiner Nähe.</p>
<button class="btn btn-primary" style="margin-top:var(--space-4)" id="walks-first-btn">
Erstes Treffen planen
</button>
</div>`;
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 += `<div class="walks-section-label">🌟 Heute</div>`;
html += heute.map(w => _walkCardHTML(w)).join('');
}
if (upcoming.length) {
html += `<div class="walks-section-label">📅 Demnächst</div>`;
html += upcoming.map(w => _walkCardHTML(w)).join('');
}
el.innerHTML = `<div class="walks-list-inner">${html}</div>`;
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 `
<div class="walks-card ${today ? 'walks-card--today' : ''}" data-id="${w.id}">
<div class="walks-card-date">
<div class="walks-card-day">${_fmtDateShort(w.datum)}</div>
<div class="walks-card-time">${w.uhrzeit}</div>
</div>
<div class="walks-card-body">
<div class="walks-card-title">${_esc(w.titel)}</div>
${w.ort_name ? `<div class="walks-card-ort">📍 ${_esc(w.ort_name)}</div>` : ''}
<div class="walks-card-meta">
<span class="walks-badge ${isFull ? 'walks-badge--full' : 'walks-badge--open'}">
${isFull ? '🔴 Voll' : `🟢 ${spots} Platz${spots !== 1 ? 'e' : ''} frei`}
</span>
<span class="walks-badge">🐾 ${w.teilnehmer_count}/${w.max_teilnehmer}</span>
${isOwn ? '<span class="walks-badge walks-badge--own">Mein Treffen</span>' : ''}
</div>
</div>
<div class="walks-card-arrow"></div>
</div>`;
}
// ----------------------------------------------------------
// 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: `<div style="background:${color};color:#fff;font-size:14px;font-weight:700;
width:32px;height:32px;border-radius:50%;display:flex;align-items:center;
justify-content:center;box-shadow:0 2px 5px rgba(0,0,0,0.3);
border:2px solid rgba(255,255,255,0.8)">🐕</div>`,
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 => `
<div class="walks-participant">
<span class="walks-participant-name">🧑 ${_esc(t.user_name)}</span>
${t.hunde ? `<span class="walks-participant-hunde">🐕 ${_esc(t.hunde)}</span>` : ''}
</div>`).join('')
: `<p style="color:var(--c-text-muted)">Noch keine Teilnehmer.</p>`;
const body = `
<div class="walks-detail-header">
<div class="walks-detail-date">
${_fmtDate(walk.datum)}<br>
<strong>um ${walk.uhrzeit} Uhr</strong>
</div>
${walk.ort_name ? `<div style="margin-top:var(--space-2);color:var(--c-text-secondary)">📍 ${_esc(walk.ort_name)}</div>` : ''}
<div style="margin-top:var(--space-2);display:flex;gap:var(--space-2);flex-wrap:wrap">
<span class="walks-badge ${isFull ? 'walks-badge--full' : 'walks-badge--open'}">
${isFull ? '🔴 Voll' : `🟢 ${spots} Platz${spots !== 1 ? 'e' : ''} frei`}
</span>
<span class="walks-badge">🐾 ${walk.teilnehmer_count}/${walk.max_teilnehmer} Teilnehmer</span>
${isOwn ? '<span class="walks-badge walks-badge--own">Dein Treffen</span>' : ''}
</div>
</div>
${walk.beschreibung ? `
<p style="margin:var(--space-4) 0;color:var(--c-text-secondary)">${_esc(walk.beschreibung)}</p>
` : ''}
<div class="walks-detail-section">
<div class="walks-detail-section-label">Teilnehmer</div>
${teilnehmerHTML}
</div>
<p style="color:var(--c-text-muted);font-size:0.8rem;margin-top:var(--space-4)">
Veranstaltet von ${_esc(walk.veranstalter_name || 'Unbekannt')}
</p>
`;
let footer;
if (isOwn) {
footer = `
<button type="button" class="btn btn-ghost btn-sm" id="wd-cancel-walk" style="color:var(--c-danger)">Stornieren</button>
<button type="button" class="btn btn-secondary flex-1" id="wd-edit">Bearbeiten</button>
<button type="button" class="btn btn-primary flex-1" id="wd-close">Schließen</button>
`;
} else if (!_appState.user) {
footer = `
<button type="button" class="btn btn-secondary flex-1" id="wd-close">Schließen</button>
<button type="button" class="btn btn-primary flex-1" id="wd-login">Anmelden zum Beitreten</button>
`;
} else if (isJoined) {
footer = `
<button type="button" class="btn btn-ghost btn-sm" id="wd-leave" style="color:var(--c-danger)">Nicht mehr teilnehmen</button>
<button type="button" class="btn btn-primary flex-1" id="wd-close">Schließen</button>
`;
} else if (isPast || isFull) {
footer = `<button type="button" class="btn btn-primary flex-1" id="wd-close">Schließen</button>`;
} else {
footer = `
<button type="button" class="btn btn-secondary flex-1" id="wd-close">Schließen</button>
<button type="button" class="btn btn-primary flex-1" id="wd-join">🐕 Mitmachen</button>
`;
}
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 => `
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;
padding:var(--space-2) 0">
<input type="checkbox" name="dog" value="${d.id}" checked>
🐕 ${_esc(d.name)}
</label>`).join('')
: `<p style="color:var(--c-text-muted)">Keine Hunde im Profil — du kannst trotzdem mitmachen.</p>`;
const body = `
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
${_fmtDate(walk.datum)} um ${walk.uhrzeit} Uhr<br>
${walk.ort_name ? `📍 ${_esc(walk.ort_name)}` : ''}
</p>
<div class="form-group">
<label class="form-label">Mit welchen Hunden?</label>
${dogsHtml}
</div>
`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="join-cancel">Abbrechen</button>
<button type="button" class="btn btn-primary flex-1" id="join-confirm">🐕 Mitmachen</button>
`;
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 = `
<form id="walk-form" autocomplete="off">
<div class="form-group">
<label class="form-label">Titel *</label>
<input class="form-control" type="text" name="titel"
value="${_esc(v.titel || '')}"
placeholder="z. B. Sonntagsspaziergang im Stadtpark" required>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Datum *</label>
<input class="form-control" type="date" name="datum"
value="${_esc(v.datum || '')}" required>
</div>
<div class="form-group">
<label class="form-label">Uhrzeit *</label>
<input class="form-control" type="time" name="uhrzeit"
value="${_esc(v.uhrzeit || '10:00')}" required>
</div>
</div>
<div class="form-group">
<label class="form-label">Treffpunkt</label>
<div style="display:flex;gap:var(--space-2)">
<input class="form-control" type="text" name="ort_name"
value="${_esc(v.ort_name || '')}"
placeholder="z. B. Parkeingang Nordseite, U-Bahn Volkspark"
style="flex:1">
<button type="button" class="btn btn-secondary" id="walk-gps-btn" title="GPS">📍</button>
</div>
<input type="hidden" name="lat" id="walk-lat" value="${v.lat || ''}">
<input type="hidden" name="lon" id="walk-lon" value="${v.lon || ''}">
<small id="walk-gps-hint" style="color:var(--c-text-secondary)">
${v.lat ? '✅ Position gespeichert' : 'GPS-Button für aktuellen Standort'}
</small>
</div>
<div class="form-group">
<label class="form-label">Max. Teilnehmer</label>
<input class="form-control" type="number" name="max_teilnehmer"
value="${v.max_teilnehmer || 10}" min="2" max="50">
</div>
<div class="form-group">
<label class="form-label">Beschreibung <span style="color:var(--c-text-secondary)">(optional)</span></label>
<textarea class="form-control" name="beschreibung" rows="3"
placeholder="Treffpunkt-Details, Streckenlänge, Hundefreundlichkeit…">${_esc(v.beschreibung || '')}</textarea>
</div>
</form>
`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="wf-cancel">Abbrechen</button>
<button type="submit" form="walk-form" class="btn btn-primary flex-1">
${isEdit ? 'Speichern' : '📅 Treffen planen'}
</button>
`;
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 };
})();

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications Offline-Cache + Push Notifications
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v20'; const CACHE_VERSION = 'by-v21';
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
// Diese Dateien werden beim Install gecacht (App Shell) // Diese Dateien werden beim Install gecacht (App Shell)