Feature: 3 Community-Features — Foto-Challenge, Stamm-Gassis, Rassen-Chip (SW by-v700)

- Foto-Challenge der Woche: DB-Tabellen, routes/challenges.py (current/submit/vote/winners),
  Scheduler-Job jeden Montag 08:00, walks.js Challenge-Tab mit Banner, Galerie, Voting-Herz
- Gassi-Zeiten-Pool: DB-Tabelle gassi_zeiten, routes/gassi_zeiten.py (CRUD + Umkreis),
  walks.js Stamm-Gassis-Tab mit Karten, Wochentag-Selector, Mitmachen→Chat
- Rassen-Treffen-Chip: GET /api/friends/same-breed, dog-profile.js zeigt Chip
  wenn andere User gleiche Rasse haben, Klick → Forum mit Rassen-Suche vorausgefüllt
This commit is contained in:
rene 2026-05-04 21:09:35 +02:00
parent d6206d378e
commit aa4849d947
10 changed files with 1322 additions and 22 deletions

View file

@ -233,6 +233,8 @@ from routes.health_docs import router as health_docs_router
from routes.passport import router as passport_router
from routes.playdate import router as playdate_router
from routes.ernaehrung import router as ernaehrung_router
from routes.challenges import router as challenges_router
from routes.gassi_zeiten import router as gassi_zeiten_router
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@ -292,6 +294,8 @@ app.include_router(health_docs_router, prefix="/api/health-docs", t
app.include_router(passport_router, prefix="/api/passport", tags=["Hundepass"])
app.include_router(playdate_router, prefix="/api/playdate", tags=["Playdate"])
app.include_router(ernaehrung_router, prefix="/api/dogs", tags=["Ernährung"])
app.include_router(challenges_router, prefix="/api/challenges", tags=["Foto-Challenge"])
app.include_router(gassi_zeiten_router, prefix="/api/gassi-zeiten", tags=["Gassi-Zeiten"])
# ------------------------------------------------------------------

View file

@ -0,0 +1,304 @@
"""BAN YARO — Foto-Challenge der Woche"""
import os
import uuid
import logging
from datetime import date, timedelta
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user, get_current_user_optional
from media_utils import convert_media, generate_preview
logger = logging.getLogger(__name__)
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
CHALLENGE_DIR = os.path.join(MEDIA_DIR, "challenges")
_CHALLENGE_THEMEN = [
"Bestes Schnüffel-Foto 👃",
"Action-Aufnahme 🏃",
"Schlafendes Tier 😴",
"Gassi im Regen 🌧️",
"Hundeblick in die Kamera 👀",
"Spielzeit mit Freunden 🐕",
"Herbstspaziergang 🍂",
"Beste Sprung-Aufnahme 🦘",
"Hund am Wasser 🌊",
"Erstes Mal im Schnee ❄️",
"Genuss-Moment 🦴",
"Versteckt im Gebüsch 🌿",
"Tierisches Selfie 🤳",
"Hund & Kind 👶",
"Hund & Katze zusammen 🐱",
"Der beste Buddel-Moment 🐾",
"Freude beim Apportieren 🎾",
"Hund in seiner Lieblingshöhle 🛋️",
"Sonnenuntergangs-Gassi 🌅",
"Hundebegegnung auf dem Spaziergang 🐕🐕",
"Ausdrucksstarker Hundeblick 😍",
"Hund im Herbstlaub 🍁",
"Welpenfoto 🍼",
"Seniorenhund im Porträt 👴",
"Lustigste Schlafposition 💤",
"Hund trägt etwas 🎀",
"Hund + Besitzer Spiegelfoto 🪞",
"Hund auf Abenteuer 🏕️",
"Beste Lauf-Action 💨",
"Hund im Café ☕",
]
def _current_week_monday() -> str:
today = date.today()
monday = today - timedelta(days=today.weekday())
return monday.isoformat()
def _current_week_sunday() -> str:
monday = date.fromisoformat(_current_week_monday())
return (monday + timedelta(days=6)).isoformat()
def _ensure_current_challenge(conn) -> int:
"""Stellt sicher dass eine Challenge für die aktuelle Woche existiert. Gibt die ID zurück."""
monday = _current_week_monday()
sunday = _current_week_sunday()
existing = conn.execute(
"SELECT id FROM foto_challenge WHERE start_date = ?", (monday,)
).fetchone()
if existing:
return existing["id"]
# Thema aus Rotation wählen (Wochennummer % Anzahl Themen)
week_num = date.today().isocalendar()[1]
thema = _CHALLENGE_THEMEN[week_num % len(_CHALLENGE_THEMEN)]
cur = conn.execute(
"INSERT INTO foto_challenge (thema, beschreibung, start_date, end_date, created_by) "
"VALUES (?, ?, ?, ?, NULL)",
(thema, f"Diese Woche: {thema}", monday, sunday)
)
return cur.lastrowid
# ------------------------------------------------------------------
# GET /api/challenges/current
# ------------------------------------------------------------------
@router.get("/current")
async def get_current_challenge(user=Depends(get_current_user_optional)):
with db() as conn:
challenge_id = _ensure_current_challenge(conn)
challenge = conn.execute(
"SELECT * FROM foto_challenge WHERE id = ?", (challenge_id,)
).fetchone()
submissions = conn.execute("""
SELECT cs.id, cs.user_id, cs.dog_id, cs.foto_url, cs.caption, cs.votes, cs.created_at,
u.name AS user_name, u.avatar_url,
d.name AS dog_name, d.foto_url AS dog_foto_url
FROM challenge_submissions cs
LEFT JOIN users u ON u.id = cs.user_id
LEFT JOIN dogs d ON d.id = cs.dog_id
WHERE cs.challenge_id = ?
ORDER BY cs.votes DESC, cs.created_at ASC
""", (challenge_id,)).fetchall()
my_submission = None
my_votes = set()
if user:
mine = conn.execute(
"SELECT id FROM challenge_submissions WHERE challenge_id=? AND user_id=?",
(challenge_id, user["id"])
).fetchone()
if mine:
my_submission = mine["id"]
voted_rows = conn.execute(
"SELECT cv.submission_id FROM challenge_votes cv "
"JOIN challenge_submissions cs ON cs.id = cv.submission_id "
"WHERE cv.user_id = ? AND cs.challenge_id = ?",
(user["id"], challenge_id)
).fetchall()
my_votes = {r["submission_id"] for r in voted_rows}
# Countdown bis Sonntag
end = date.fromisoformat(challenge["end_date"])
days_left = (end - date.today()).days + 1
result_subs = []
for s in submissions:
sd = dict(s)
sd["i_voted"] = (sd["id"] in my_votes) if user else False
result_subs.append(sd)
return {
"challenge": dict(challenge),
"submissions": result_subs,
"my_submission_id": my_submission,
"days_left": max(0, days_left),
}
# ------------------------------------------------------------------
# POST /api/challenges/{id}/submit
# ------------------------------------------------------------------
@router.post("/{challenge_id}/submit", status_code=201)
async def submit_photo(
challenge_id: int,
caption: Optional[str] = Form(None),
dog_id: Optional[int] = Form(None),
foto: UploadFile = File(...),
user=Depends(get_current_user),
):
with db() as conn:
challenge = conn.execute(
"SELECT * FROM foto_challenge WHERE id = ?", (challenge_id,)
).fetchone()
if not challenge:
raise HTTPException(404, "Challenge nicht gefunden.")
today = date.today().isoformat()
if today > challenge["end_date"]:
raise HTTPException(400, "Die Challenge ist bereits beendet.")
# Doppelt-Check
existing = conn.execute(
"SELECT id FROM challenge_submissions WHERE challenge_id=? AND user_id=?",
(challenge_id, user["id"])
).fetchone()
if existing:
raise HTTPException(409, "Du hast bereits ein Foto eingereicht.")
# Foto speichern
os.makedirs(CHALLENGE_DIR, exist_ok=True)
orig_filename = foto.filename or "foto.jpg"
ext = os.path.splitext(orig_filename)[1] or ".jpg"
base = uuid.uuid4().hex
raw = await foto.read()
# HEIC→JPEG Konvertierung falls nötig
try:
converted, out_ext = convert_media(raw, orig_filename)
except Exception:
converted, out_ext = raw, ext
save_filename = f"{base}{out_ext}"
save_path = os.path.join(CHALLENGE_DIR, save_filename)
with open(save_path, "wb") as f:
f.write(converted)
foto_url = f"/media/challenges/{save_filename}"
# Preview
try:
preview = generate_preview(converted, out_ext)
if preview:
prev_path = os.path.join(CHALLENGE_DIR, f"{base}_preview.webp")
with open(prev_path, "wb") as f:
f.write(preview)
except Exception:
pass
with db() as conn:
cur = conn.execute(
"INSERT INTO challenge_submissions (challenge_id, user_id, dog_id, foto_url, caption) "
"VALUES (?, ?, ?, ?, ?)",
(challenge_id, user["id"], dog_id, foto_url, caption)
)
row = conn.execute("""
SELECT cs.*, u.name AS user_name, d.name AS dog_name
FROM challenge_submissions cs
LEFT JOIN users u ON u.id = cs.user_id
LEFT JOIN dogs d ON d.id = cs.dog_id
WHERE cs.id = ?
""", (cur.lastrowid,)).fetchone()
return dict(row)
# ------------------------------------------------------------------
# POST /api/challenges/submissions/{id}/vote — Toggle-Vote
# ------------------------------------------------------------------
@router.post("/submissions/{submission_id}/vote")
async def vote_submission(submission_id: int, user=Depends(get_current_user)):
with db() as conn:
sub = conn.execute(
"SELECT * FROM challenge_submissions WHERE id = ?", (submission_id,)
).fetchone()
if not sub:
raise HTTPException(404, "Einreichung nicht gefunden.")
if sub["user_id"] == user["id"]:
raise HTTPException(400, "Du kannst nicht für dein eigenes Foto abstimmen.")
existing = conn.execute(
"SELECT id FROM challenge_votes WHERE submission_id=? AND user_id=?",
(submission_id, user["id"])
).fetchone()
if existing:
# Toggle: Vote entfernen
conn.execute(
"DELETE FROM challenge_votes WHERE submission_id=? AND user_id=?",
(submission_id, user["id"])
)
conn.execute(
"UPDATE challenge_submissions SET votes = MAX(0, votes - 1) WHERE id=?",
(submission_id,)
)
voted = False
else:
conn.execute(
"INSERT INTO challenge_votes (submission_id, user_id) VALUES (?, ?)",
(submission_id, user["id"])
)
conn.execute(
"UPDATE challenge_submissions SET votes = votes + 1 WHERE id=?",
(submission_id,)
)
voted = True
votes = conn.execute(
"SELECT votes FROM challenge_submissions WHERE id=?", (submission_id,)
).fetchone()["votes"]
return {"voted": voted, "votes": votes}
# ------------------------------------------------------------------
# GET /api/challenges/winners — letzte 4 Gewinner
# ------------------------------------------------------------------
@router.get("/winners")
async def get_winners():
with db() as conn:
# Vergangene Challenges (ohne aktuelle Woche)
monday = _current_week_monday()
challenges = conn.execute(
"SELECT id, thema, start_date, end_date FROM foto_challenge "
"WHERE end_date < ? ORDER BY end_date DESC LIMIT 4",
(monday,)
).fetchall()
winners = []
for ch in challenges:
winner = conn.execute("""
SELECT cs.id, cs.user_id, cs.foto_url, cs.caption, cs.votes,
u.name AS user_name, u.avatar_url,
d.name AS dog_name
FROM challenge_submissions cs
LEFT JOIN users u ON u.id = cs.user_id
LEFT JOIN dogs d ON d.id = cs.dog_id
WHERE cs.challenge_id = ?
ORDER BY cs.votes DESC, cs.created_at ASC
LIMIT 1
""", (ch["id"],)).fetchone()
winners.append({
"challenge": dict(ch),
"winner": dict(winner) if winner else None,
})
return winners

View file

@ -342,3 +342,56 @@ async def remove_friend(friend_user_id: int, user=Depends(get_current_user)):
AND ((requester_id=? AND addressee_id=?) OR (requester_id=? AND addressee_id=?))
""", (uid, friend_user_id, friend_user_id, uid))
return {"ok": True}
# ------------------------------------------------------------------
# GET /api/friends/same-breed — andere User mit gleicher Rasse
# ------------------------------------------------------------------
@router.get("/same-breed")
async def same_breed(user=Depends(get_current_user)):
"""Findet andere User mit Hunden derselben Rasse. Gibt Anzahl + Forum-Suche zurück."""
uid = user["id"]
with db() as conn:
# Rassen des eingeloggten Users
my_dogs = conn.execute(
"SELECT rasse FROM dogs WHERE user_id=? AND rasse IS NOT NULL AND rasse != ''",
(uid,)
).fetchall()
if not my_dogs:
return {"count": 0, "rassen": [], "forum_query": None}
rassen = list({d["rasse"].strip() for d in my_dogs if d["rasse"]})
# Andere User (nicht ich) die eine dieser Rassen haben
ph = ",".join("?" * len(rassen))
count_row = conn.execute(f"""
SELECT COUNT(DISTINCT d.user_id) AS cnt
FROM dogs d
WHERE d.user_id != ?
AND d.rasse IN ({ph})
""", (uid, *rassen)).fetchone()
count = count_row["cnt"] if count_row else 0
# Für jede Rasse: wie viele andere User
rassen_detail = []
for rasse in rassen:
n = conn.execute(
"SELECT COUNT(DISTINCT user_id) AS cnt FROM dogs "
"WHERE user_id != ? AND rasse = ?",
(uid, rasse)
).fetchone()["cnt"]
if n > 0:
rassen_detail.append({"rasse": rasse, "count": n})
rassen_detail.sort(key=lambda x: -x["count"])
# Forum-Suche-Link für die häufigste Rasse
forum_query = rassen_detail[0]["rasse"] if rassen_detail else None
return {
"count": count,
"rassen": rassen_detail,
"forum_query": forum_query,
}

View file

@ -0,0 +1,190 @@
"""BAN YARO — Gassi-Zeiten-Pool (regelmäßige Gassi-Zeiten mit Gleichgesinnten)"""
import json
import math
import logging
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
logger = logging.getLogger(__name__)
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))
class GassiZeitCreate(BaseModel):
dog_id: Optional[int] = None
wochentage: List[str] # ["mo", "mi", "fr"]
uhrzeit: str # "17:00"
ort_name: Optional[str] = None
lat: Optional[float] = None
lon: Optional[float] = None
radius_m: int = 500
notiz: Optional[str] = None
class GassiZeitUpdate(BaseModel):
aktiv: Optional[int] = None
# ------------------------------------------------------------------
# GET /api/gassi-zeiten — alle in der Nähe (oder eigene)
# ------------------------------------------------------------------
@router.get("")
async def list_gassi_zeiten(
lat: Optional[float] = None,
lon: Optional[float] = None,
radius: int = 5000, # Meter
nur_eigene: bool = False,
user=Depends(get_current_user),
):
with db() as conn:
if nur_eigene:
rows = conn.execute("""
SELECT gz.*, u.name AS user_name, u.avatar_url,
d.name AS dog_name, d.foto_url AS dog_foto_url, d.rasse AS dog_rasse
FROM gassi_zeiten gz
LEFT JOIN users u ON u.id = gz.user_id
LEFT JOIN dogs d ON d.id = gz.dog_id
WHERE gz.user_id = ?
ORDER BY gz.uhrzeit ASC
""", (user["id"],)).fetchall()
else:
rows = conn.execute("""
SELECT gz.*, u.name AS user_name, u.avatar_url,
d.name AS dog_name, d.foto_url AS dog_foto_url, d.rasse AS dog_rasse
FROM gassi_zeiten gz
LEFT JOIN users u ON u.id = gz.user_id
LEFT JOIN dogs d ON d.id = gz.dog_id
WHERE gz.aktiv = 1
ORDER BY gz.uhrzeit ASC
""").fetchall()
result = []
for r in rows:
d = dict(r)
# wochentage JSON parsen
try:
d["wochentage"] = json.loads(d["wochentage"]) if isinstance(d["wochentage"], str) else d["wochentage"]
except Exception:
d["wochentage"] = []
d["is_mine"] = (d["user_id"] == user["id"])
# Distanz-Filter
if lat is not None and lon is not None and d.get("lat") and d.get("lon"):
dist = _haversine(lat, lon, d["lat"], d["lon"])
if not nur_eigene and dist > radius:
continue
d["distance_m"] = int(dist)
else:
d["distance_m"] = None
result.append(d)
# Sortierung: eigene zuerst, dann nach Distanz
result.sort(key=lambda x: (0 if x["is_mine"] else 1, x.get("distance_m") or 99999))
return result
# ------------------------------------------------------------------
# POST /api/gassi-zeiten — eigene Zeit anlegen
# ------------------------------------------------------------------
@router.post("", status_code=201)
async def create_gassi_zeit(data: GassiZeitCreate, user=Depends(get_current_user)):
if not data.wochentage:
raise HTTPException(400, "Mindestens ein Wochentag muss angegeben werden.")
if not data.uhrzeit:
raise HTTPException(400, "Uhrzeit muss angegeben werden.")
wochentage_json = json.dumps(data.wochentage)
with db() as conn:
# Hund-Prüfung
if data.dog_id:
dog = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?", (data.dog_id, user["id"])
).fetchone()
if not dog:
raise HTTPException(403, "Hund nicht gefunden oder gehört nicht dir.")
cur = conn.execute("""
INSERT INTO gassi_zeiten (user_id, dog_id, wochentage, uhrzeit,
ort_name, lat, lon, radius_m, notiz)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (user["id"], data.dog_id, wochentage_json, data.uhrzeit,
data.ort_name, data.lat, data.lon, data.radius_m, data.notiz))
row = conn.execute("""
SELECT gz.*, u.name AS user_name, d.name AS dog_name, d.foto_url AS dog_foto_url
FROM gassi_zeiten gz
LEFT JOIN users u ON u.id = gz.user_id
LEFT JOIN dogs d ON d.id = gz.dog_id
WHERE gz.id = ?
""", (cur.lastrowid,)).fetchone()
result = dict(row)
try:
result["wochentage"] = json.loads(result["wochentage"])
except Exception:
pass
result["is_mine"] = True
return result
# ------------------------------------------------------------------
# PATCH /api/gassi-zeiten/{id} — pausieren / aktivieren
# ------------------------------------------------------------------
@router.patch("/{gz_id}")
async def update_gassi_zeit(gz_id: int, data: GassiZeitUpdate, user=Depends(get_current_user)):
with db() as conn:
gz = conn.execute(
"SELECT * FROM gassi_zeiten WHERE id=?", (gz_id,)
).fetchone()
if not gz:
raise HTTPException(404, "Gassi-Zeit nicht gefunden.")
if gz["user_id"] != user["id"]:
raise HTTPException(403, "Nicht deine Gassi-Zeit.")
updates = data.model_dump(exclude_none=True)
if updates:
cols = ", ".join(f"{k} = ?" for k in updates)
conn.execute(f"UPDATE gassi_zeiten SET {cols} WHERE id=?", [*updates.values(), gz_id])
row = conn.execute(
"SELECT * FROM gassi_zeiten WHERE id=?", (gz_id,)
).fetchone()
result = dict(row)
try:
result["wochentage"] = json.loads(result["wochentage"])
except Exception:
pass
result["is_mine"] = True
return result
# ------------------------------------------------------------------
# DELETE /api/gassi-zeiten/{id}
# ------------------------------------------------------------------
@router.delete("/{gz_id}", status_code=204)
async def delete_gassi_zeit(gz_id: int, user=Depends(get_current_user)):
with db() as conn:
gz = conn.execute(
"SELECT * FROM gassi_zeiten WHERE id=?", (gz_id,)
).fetchone()
if not gz:
raise HTTPException(404, "Gassi-Zeit nicht gefunden.")
if gz["user_id"] != user["id"]:
raise HTTPException(403, "Nicht deine Gassi-Zeit.")
conn.execute("DELETE FROM gassi_zeiten WHERE id=?", (gz_id,))

View file

@ -156,6 +156,14 @@ def start():
replace_existing=True,
misfire_grace_time=3600,
)
# Jeden Montag 08:00 Uhr — Neue Foto-Challenge anlegen
_scheduler.add_job(
_job_new_foto_challenge,
CronTrigger(day_of_week='mon', hour=8, minute=0),
id="new_foto_challenge",
replace_existing=True,
misfire_grace_time=3600,
)
# Täglich 07:00 Uhr — Goldene Gassi-Stunde
_scheduler.add_job(
_job_golden_gassi_hour,
@ -181,7 +189,7 @@ def start():
misfire_grace_time=3600,
)
_scheduler.start()
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00, Goldene-Gassi-Stunde 07:00, Jahrestags-Erinnerungen 09:00, Monatlicher-Rückblick 1. des Monats 10:00. OSM-Cache: on-demand (kein Prewarm).")
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00, Goldene-Gassi-Stunde 07:00, Jahrestags-Erinnerungen 09:00, Monatlicher-Rückblick 1. des Monats 10:00, Foto-Challenge Mo 08:00. OSM-Cache: on-demand (kein Prewarm).")
def stop():
@ -1544,6 +1552,46 @@ async def _job_monthly_recap():
_log_job("monthly_recap", "ok", f"{sent_total} Push für {month_label}")
async def _job_new_foto_challenge():
"""Jeden Montag 08:00 — neue Foto-Challenge für die aktuelle Woche anlegen."""
from datetime import date, timedelta
from routes.challenges import _CHALLENGE_THEMEN, _current_week_monday, _current_week_sunday
monday = _current_week_monday()
sunday = _current_week_sunday()
week_num = date.today().isocalendar()[1]
thema = _CHALLENGE_THEMEN[week_num % len(_CHALLENGE_THEMEN)]
with db() as conn:
existing = conn.execute(
"SELECT id FROM foto_challenge WHERE start_date = ?", (monday,)
).fetchone()
if existing:
logger.info(f"Foto-Challenge: Woche {monday} bereits vorhanden (id={existing['id']}).")
_log_job("new_foto_challenge", "ok", f"Bereits vorhanden für {monday}")
return
cur = conn.execute(
"INSERT INTO foto_challenge (thema, beschreibung, start_date, end_date, created_by) "
"VALUES (?, ?, ?, ?, NULL)",
(thema, f"Diese Woche: {thema}", monday, sunday)
)
challenge_id = cur.lastrowid
# Push an alle User
send_push_to_all({
"type": "foto_challenge",
"title": "📸 Neue Foto-Challenge!",
"body": f"Diese Woche: {thema} — mach mit!",
"data": {"page": "walks", "tab": "challenge"},
"tag": f"challenge-{monday}",
})
logger.info(f"Foto-Challenge angelegt: '{thema}' für {monday}{sunday} (id={challenge_id}).")
_log_job("new_foto_challenge", "ok", f"'{thema}' für {monday}")
async def _fetch_hourly_weather(lat: float, lon: float) -> list[dict]:
"""Holt stündliche Wetterdaten für heute von Open-Meteo."""
import httpx

View file

@ -7304,6 +7304,20 @@ svg.empty-state-icon {
color: var(--c-text-secondary);
line-height: 1.2;
}
.exp-kachel-jahr {
font-size: 9px;
color: var(--c-text-muted);
margin-top: 2px;
line-height: 1.2;
}
.exp-kachel-add {
display: flex;
align-items: center;
gap: 2px;
font-size: 10px;
color: var(--c-text-muted);
margin-top: 3px;
}
/* ---- Sektion-Block (Verlauf etc.) ---- */
.exp-section {
@ -7479,6 +7493,36 @@ svg.empty-state-icon {
border-radius: 999px;
padding: 1px 6px;
}
.exp-dog-selector {
display: flex;
gap: 8px;
padding: 10px 16px 4px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.exp-dog-selector::-webkit-scrollbar { display: none; }
.exp-dog-pill {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 14px;
border-radius: 999px;
border: 1px solid var(--c-border);
background: var(--c-bg-card);
color: var(--c-text-secondary);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
cursor: pointer;
white-space: nowrap;
transition: background .15s, color .15s, border-color .15s;
}
.exp-dog-pill.active {
background: var(--c-primary);
color: #fff;
border-color: var(--c-primary);
}
/* Rechte Spalte: Betrag + Löschen-Icon */
.exp-entry-right {
@ -8133,3 +8177,189 @@ svg.empty-state-icon {
0%, 100% { opacity: 0.5; }
50% { opacity: 0.25; }
}
/* ── COMMUNITY-FEATURES ──────────────────────────────────── */
/* Walks-Tab-Bar */
.walks-tab-panel { display: flex; flex-direction: column; min-height: 0; flex: 1; }
/* Foto-Challenge */
.challenge-banner {
background: linear-gradient(135deg, var(--c-amber, #f59e0b), var(--c-primary, #C4843A));
border-radius: var(--radius-lg);
margin: var(--space-4);
overflow: hidden;
}
.challenge-banner-inner {
padding: var(--space-5) var(--space-4);
color: #fff;
}
.challenge-thema {
font-size: var(--text-xl);
font-weight: var(--weight-bold);
line-height: 1.2;
margin-bottom: var(--space-2);
}
.challenge-meta {
font-size: var(--text-sm);
opacity: 0.88;
}
.challenge-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: var(--space-3);
padding: 0 var(--space-4) var(--space-6);
}
.challenge-sub-card {
background: var(--c-surface);
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.challenge-sub-card img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
display: block;
cursor: pointer;
}
.challenge-sub-info {
padding: var(--space-2);
}
.challenge-sub-user {
font-size: var(--text-xs);
color: var(--c-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
}
.challenge-sub-caption {
font-size: var(--text-xs);
color: var(--c-text);
margin-bottom: var(--space-1);
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.challenge-vote-btn {
border: none;
background: transparent;
color: var(--c-text-secondary);
font-size: var(--text-xs);
cursor: pointer;
padding: 2px 6px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
gap: 3px;
}
.challenge-vote-btn.voted {
color: var(--c-danger, #ef4444);
}
.challenge-winners { border-top: 1px solid var(--c-border); }
.challenge-winners-row {
display: flex;
gap: var(--space-3);
overflow-x: auto;
padding: var(--space-2) var(--space-4) var(--space-3);
scroll-snap-type: x mandatory;
}
.challenge-winner-chip {
display: flex;
align-items: center;
gap: var(--space-2);
background: var(--c-surface-alt, #fdf6ef);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
min-width: 160px;
flex-shrink: 0;
scroll-snap-align: start;
}
.challenge-winner-chip img {
width: 40px;
height: 40px;
border-radius: var(--radius-full);
object-fit: cover;
flex-shrink: 0;
}
/* Wochentag-Selector */
.wd-selector {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.wd-btn {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
padding: 4px 10px;
border: 1.5px solid var(--c-border);
border-radius: var(--radius-full);
font-size: var(--text-sm);
user-select: none;
transition: background .15s, border-color .15s;
}
.wd-btn input { display: none; }
.wd-btn:has(input:checked) {
background: var(--c-primary, #C4843A);
border-color: var(--c-primary, #C4843A);
color: #fff;
}
/* Gassi-Zeit-Karten */
.gassi-zeit-card {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--c-border);
background: var(--c-surface);
}
.gz-avatar {
width: 48px;
height: 48px;
border-radius: var(--radius-full);
overflow: hidden;
flex-shrink: 0;
background: var(--c-surface-alt);
display: flex;
align-items: center;
justify-content: center;
}
.gz-avatar img { width: 100%; height: 100%; object-fit: cover; }
.gz-avatar-placeholder { font-size: 1.5rem; color: var(--c-text-secondary); }
.gz-body { flex: 1; min-width: 0; }
.gz-name {
font-weight: var(--weight-semibold);
font-size: var(--text-sm);
display: flex;
align-items: center;
gap: var(--space-1);
flex-wrap: wrap;
}
.gz-meta { font-size: var(--text-xs); color: var(--c-text-secondary); margin-top: 2px; }
.gz-notiz { font-size: var(--text-xs); color: var(--c-text-secondary); margin-top: 2px; font-style: italic; }
.gz-actions { display: flex; gap: var(--space-1); flex-shrink: 0; }
/* Rassen-Community-Chip */
.breed-community-chip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
background: var(--c-surface-alt, #fdf6ef);
border: 1.5px solid var(--c-amber, #f59e0b);
border-radius: var(--radius-full);
padding: 6px 16px;
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--c-text);
cursor: pointer;
transition: background .15s;
}
.breed-community-chip:hover, .breed-community-chip:active {
background: #fff3e0;
}

View file

@ -97,8 +97,11 @@ window.Page_dog_profile = (() => {
<h2 style="font-size:var(--text-2xl);font-weight:700;
color:var(--c-text);margin:0 0 var(--space-1)">${_esc(dog.name)}</h2>
${dog.rasse
? `<p style="color:var(--c-text-secondary);margin:0 0 var(--space-5)">${_esc(dog.rasse)}</p>`
: `<p style="margin:0 0 var(--space-5)"></p>`}
? `<p style="color:var(--c-text-secondary);margin:0 0 var(--space-2)">${_esc(dog.rasse)}</p>`
: `<p style="margin:0 0 var(--space-2)"></p>`}
<!-- Rassen-Community-Chip (wird async geladen) -->
<div id="dp-same-breed-chip" style="margin-bottom:var(--space-4)"></div>
<!-- Info-Grid -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);
@ -245,6 +248,9 @@ window.Page_dog_profile = (() => {
// Pflegetipps laden
_loadPflegeTipps(dog);
// Rassen-Community-Chip laden (falls Rasse bekannt)
if (dog.rasse) _loadSameBreedChip();
// Sitter-Zugang laden (nur für Besitzer)
if (dog.user_id === _appState.user?.id) {
_loadSittingAccess(dog.id);
@ -2386,6 +2392,32 @@ window.Page_dog_profile = (() => {
}
// ----------------------------------------------------------
// RASSEN-COMMUNITY-CHIP
// ----------------------------------------------------------
async function _loadSameBreedChip() {
const el = document.getElementById('dp-same-breed-chip');
if (!el) return;
try {
const data = await API.get('friends/same-breed');
if (!data || data.count === 0) return;
const hauptRasse = data.rassen[0]?.rasse || '';
const label = data.count === 1
? `1 anderer ${_esc(hauptRasse)}-Halter in der App`
: `${data.count} andere ${_esc(hauptRasse)}-Halter in der App`;
el.innerHTML = `
<button class="breed-community-chip" id="dp-breed-chip-btn">
🐕 ${label} &mdash; Forum ansehen
</button>
`;
document.getElementById('dp-breed-chip-btn')?.addEventListener('click', () => {
App.navigate('forum', false, { search: hauptRasse });
});
} catch {}
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------

View file

@ -62,12 +62,21 @@ window.Page_forum = (() => {
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
async function init(container, appState, params = {}) {
_container = container;
_appState = appState;
_render();
_loadHdmCard();
_loadThreads(true);
// Rassen-Suche vorausfüllen (Feature 3: Same-Breed-Chip)
if (params.search) {
const searchInput = document.getElementById('forum-search');
if (searchInput) {
searchInput.value = params.search;
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
}
}
}
function refresh() {

View file

@ -9,9 +9,12 @@ window.Page_walks = (() => {
let _appState = null;
let _data = [];
let _view = 'liste'; // 'liste' | 'karte'
let _tab = 'treffen'; // 'treffen' | 'challenge' | 'stamm'
let _map = null;
let _markers = [];
let _userPos = null;
let _challengeData = null;
let _gassiZeiten = [];
// _esc ersetzt durch UI.escape()
@ -56,9 +59,17 @@ window.Page_walks = (() => {
_loadData();
}
function refresh() { _loadData(); }
function refresh() {
_loadData();
if (_tab === 'challenge') _loadChallenge();
if (_tab === 'stamm') _loadGassiZeiten();
}
function onDogChange() {}
function openNew() { _showCreateForm(); }
function openNew() {
if (_tab === 'challenge') { _showSubmitForm(); return; }
if (_tab === 'stamm') { _showGassiZeitForm(); return; }
_showCreateForm();
}
// ----------------------------------------------------------
// RENDER — Grundstruktur
@ -67,30 +78,63 @@ window.Page_walks = (() => {
_container.innerHTML = `
<div class="walks-layout">
<!-- Toolbar -->
<div class="by-toolbar">
<div class="walks-view-toggle" id="walks-view-toggle">
<button class="walks-view-btn active" data-view="liste">${UI.icon('list')} Liste</button>
<button class="walks-view-btn" data-view="karte">${UI.icon('map-trifold')} Karte</button>
</div>
<button class="btn btn-primary btn-sm" id="walks-create-btn">${UI.icon('plus')} Treffen planen</button>
<!-- Tab-Bar -->
<div class="by-tabs" id="walks-tab-bar" style="padding:var(--space-3) var(--space-4) 0">
<button class="by-tab active" data-tab="treffen">${UI.icon('paw-print')} Treffen</button>
<button class="by-tab" data-tab="challenge">${UI.icon('camera')} Challenge</button>
<button class="by-tab" data-tab="stamm">${UI.icon('clock')} Stamm-Gassis</button>
</div>
<!-- Liste -->
<div id="walks-list-view" class="walks-content">
<div id="walks-list">
<!-- Tab: Treffen -->
<div id="walks-tab-treffen" class="walks-tab-panel">
<!-- Toolbar -->
<div class="by-toolbar">
<div class="walks-view-toggle" id="walks-view-toggle">
<button class="walks-view-btn active" data-view="liste">${UI.icon('list')} Liste</button>
<button class="walks-view-btn" data-view="karte">${UI.icon('map-trifold')} Karte</button>
</div>
<button class="btn btn-primary btn-sm" id="walks-create-btn">${UI.icon('plus')} 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>
<!-- Tab: Challenge -->
<div id="walks-tab-challenge" class="walks-tab-panel" style="display:none">
<div id="challenge-content">
<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>
<!-- Tab: Stamm-Gassis -->
<div id="walks-tab-stamm" class="walks-tab-panel" style="display:none">
<div class="by-toolbar">
<span style="font-weight:600;color:var(--c-text)">${UI.icon('clock')} Stamm-Gassi-Zeiten</span>
<button class="btn btn-primary btn-sm" id="gassi-zeit-add-btn">${UI.icon('plus')} Meine Zeit eintragen</button>
</div>
<div id="gassi-zeiten-content">
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt</p>
</div>
</div>
</div>
`;
// Tab-Bar Events
document.getElementById('walks-tab-bar').addEventListener('click', e => {
const btn = e.target.closest('.by-tab');
if (!btn) return;
_switchTab(btn.dataset.tab);
});
document.getElementById('walks-view-toggle').addEventListener('click', e => {
const btn = e.target.closest('.walks-view-btn');
if (!btn) return;
@ -105,6 +149,23 @@ window.Page_walks = (() => {
}
_showCreateForm();
});
document.getElementById('gassi-zeit-add-btn').addEventListener('click', () => {
if (!_appState.user) { UI.toast.warning('Bitte zuerst anmelden.'); App.navigate('settings'); return; }
_showGassiZeitForm();
});
}
function _switchTab(tab) {
_tab = tab;
document.querySelectorAll('.by-tab').forEach(b =>
b.classList.toggle('active', b.dataset.tab === tab));
document.querySelectorAll('.walks-tab-panel').forEach(p => p.style.display = 'none');
const panel = document.getElementById(`walks-tab-${tab}`);
if (panel) panel.style.display = '';
if (tab === 'challenge' && !_challengeData) _loadChallenge();
if (tab === 'stamm' && !_gassiZeiten.length) _loadGassiZeiten();
}
function _switchView(view) {
@ -1038,6 +1099,375 @@ window.Page_walks = (() => {
});
}
// ==============================================================
// FEATURE 1: Foto-Challenge der Woche
// ==============================================================
async function _loadChallenge() {
const el = document.getElementById('challenge-content');
if (!el) return;
try {
_challengeData = await API.get('challenges/current');
_renderChallenge();
} catch (e) {
el.innerHTML = `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Konnte Challenge nicht laden.</p>`;
}
}
function _renderChallenge() {
const el = document.getElementById('challenge-content');
if (!el || !_challengeData) return;
const { challenge, submissions, my_submission_id, days_left } = _challengeData;
const canSubmit = _appState.user && !my_submission_id;
const dayLabel = days_left === 1 ? '1 Tag' : `${days_left} Tage`;
el.innerHTML = `
<div class="challenge-banner">
<div class="challenge-banner-inner">
<div class="challenge-thema">${UI.escape(challenge.thema)}</div>
<div class="challenge-meta">
${UI.icon('calendar')} ${_fmtDate(challenge.start_date)} ${_fmtDate(challenge.end_date)}
&nbsp;·&nbsp; ${UI.icon('timer')} Noch ${dayLabel}
</div>
${canSubmit ? `<button class="btn btn-primary btn-sm" id="challenge-submit-btn" style="margin-top:var(--space-3)">${UI.icon('camera')} Foto einreichen</button>` : ''}
${my_submission_id ? `<span class="badge badge-success" style="margin-top:var(--space-2)">${UI.icon('check')} Du hast bereits teilgenommen</span>` : ''}
</div>
</div>
<div class="challenge-winners" id="challenge-winners-section">
<h4 style="padding:var(--space-3) var(--space-4);margin:0;color:var(--c-text-secondary);font-size:var(--text-xs);text-transform:uppercase;letter-spacing:.05em">Letzte Gewinner</h4>
<div id="challenge-winners-list"><p style="color:var(--c-text-secondary);padding:0 var(--space-4);font-size:var(--text-sm)">Lädt</p></div>
</div>
<div style="padding:var(--space-4) var(--space-4) var(--space-2)">
<h4 style="margin:0;font-size:var(--text-sm);font-weight:600;color:var(--c-text)">
${UI.icon('images')} Einreichungen dieser Woche (${submissions.length})
</h4>
</div>
<div class="challenge-gallery" id="challenge-gallery">
${submissions.length === 0
? `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8);grid-column:1/-1">Noch keine Fotos — sei der Erste! 📸</p>`
: submissions.map(s => _challengeSubmissionCard(s)).join('')}
</div>
`;
// Submit-Button
const submitBtn = document.getElementById('challenge-submit-btn');
if (submitBtn) submitBtn.addEventListener('click', _showSubmitForm);
// Vote-Buttons
el.querySelectorAll('.challenge-vote-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!_appState.user) { UI.toast.warning('Bitte anmelden.'); return; }
const subId = parseInt(btn.dataset.id);
try {
const res = await API.post(`challenges/submissions/${subId}/vote`, {});
btn.querySelector('.vote-count').textContent = res.votes;
btn.classList.toggle('voted', res.voted);
} catch (e) { UI.toast.error(e.message || 'Fehler beim Abstimmen.'); }
});
});
// Gewinner laden
_loadChallengeWinners();
}
function _challengeSubmissionCard(s) {
const voted = s.i_voted;
return `
<div class="challenge-sub-card">
<img src="${UI.escape(s.foto_url)}" alt="Challenge-Foto" loading="lazy"
onerror="this.src='/icons/icon-192.png'"
onclick="UI.lightbox?.show?.([{ url:'${UI.escape(s.foto_url)}' }], 0)">
<div class="challenge-sub-info">
<div class="challenge-sub-user">${UI.icon('user')} ${UI.escape(s.user_name || 'Anonym')}
${s.dog_name ? ` · 🐕 ${UI.escape(s.dog_name)}` : ''}</div>
${s.caption ? `<div class="challenge-sub-caption">${UI.escape(s.caption)}</div>` : ''}
<button class="challenge-vote-btn ${voted ? 'voted' : ''}" data-id="${s.id}">
${UI.icon(voted ? 'heart-fill' : 'heart')} <span class="vote-count">${s.votes}</span>
</button>
</div>
</div>
`;
}
async function _loadChallengeWinners() {
const el = document.getElementById('challenge-winners-list');
if (!el) return;
try {
const winners = await API.get('challenges/winners');
if (!winners.length) { el.innerHTML = '<p style="color:var(--c-text-secondary);padding:0 var(--space-4);font-size:var(--text-sm)">Noch keine vergangenen Challenges.</p>'; return; }
el.innerHTML = `<div class="challenge-winners-row">` +
winners.map(w => {
if (!w.winner) return `<div class="challenge-winner-chip"><span>${UI.escape(w.challenge.thema)}</span><small>Kein Gewinner</small></div>`;
return `<div class="challenge-winner-chip">
<img src="${UI.escape(w.winner.foto_url)}" alt="Gewinner" onerror="this.src='/icons/icon-192.png'">
<div>
<div style="font-weight:600;font-size:var(--text-xs)">${UI.escape(w.challenge.thema)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.escape(w.winner.user_name)} · ${w.winner.votes} </div>
</div>
</div>`;
}).join('') +
`</div>`;
} catch {}
}
async function _showSubmitForm() {
if (!_challengeData) return;
const dogs = _appState.dogs || [];
const dogOptions = dogs.map(d => `<option value="${d.id}">${UI.escape(d.name)}</option>`).join('');
UI.modal.open({
title: `📸 ${UI.escape(_challengeData.challenge.thema)}`,
body: `
<form id="challenge-submit-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div class="form-group">
<label>Foto *</label>
<input type="file" id="challenge-foto-input" accept="image/*" required style="width:100%">
</div>
${dogs.length ? `<div class="form-group">
<label>Hund</label>
<select id="challenge-dog-select" style="width:100%">
<option value="">Kein Hund</option>
${dogOptions}
</select>
</div>` : ''}
<div class="form-group">
<label>Bildunterschrift</label>
<input type="text" id="challenge-caption" placeholder="z.B. Mein Bello beim besten Schnüffeln…" maxlength="200" style="width:100%">
</div>
</form>
`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="challenge-submit-ok">Einreichen</button>
`,
});
document.getElementById('challenge-submit-ok').addEventListener('click', async () => {
const fotoInput = document.getElementById('challenge-foto-input');
if (!fotoInput.files.length) { UI.toast.warning('Bitte ein Foto auswählen.'); return; }
const caption = document.getElementById('challenge-caption')?.value?.trim() || '';
const dogSelect = document.getElementById('challenge-dog-select');
const dogId = dogSelect ? (parseInt(dogSelect.value) || '') : '';
const fd = new FormData();
fd.append('foto', fotoInput.files[0]);
if (caption) fd.append('caption', caption);
if (dogId) fd.append('dog_id', dogId);
try {
await API.upload(`challenges/${_challengeData.challenge.id}/submit`, fd);
UI.toast.success('Foto eingereicht! Viel Erfolg 🎉');
UI.modal.close();
_challengeData = null;
_loadChallenge();
} catch (e) { UI.toast.error(e.message || 'Fehler beim Einreichen.'); }
});
}
// ==============================================================
// FEATURE 2: Gassi-Zeiten-Pool (Stamm-Gassis)
// ==============================================================
const _WOCHENTAGE = [
{ key: 'mo', label: 'Mo' }, { key: 'di', label: 'Di' }, { key: 'mi', label: 'Mi' },
{ key: 'do', label: 'Do' }, { key: 'fr', label: 'Fr' }, { key: 'sa', label: 'Sa' },
{ key: 'so', label: 'So' },
];
async function _loadGassiZeiten() {
const el = document.getElementById('gassi-zeiten-content');
if (!el) return;
try {
const params = _userPos ? `?lat=${_userPos.lat}&lon=${_userPos.lon}&radius=10000` : '';
_gassiZeiten = await API.get(`gassi-zeiten${params}`);
_renderGassiZeiten();
} catch (e) {
el.innerHTML = `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Konnte Gassi-Zeiten nicht laden.</p>`;
}
}
function _renderGassiZeiten() {
const el = document.getElementById('gassi-zeiten-content');
if (!el) return;
if (!_gassiZeiten.length) {
el.innerHTML = `
<div style="text-align:center;padding:var(--space-8);color:var(--c-text-secondary)">
${UI.icon('clock')}
<p>Noch keine Stamm-Gassi-Zeiten in deiner Nähe.</p>
<p style="font-size:var(--text-sm)">Trag deine regelmäßigen Zeiten ein andere finden dich dann!</p>
</div>`;
return;
}
const myZeiten = _gassiZeiten.filter(z => z.is_mine);
const andereZeiten = _gassiZeiten.filter(z => !z.is_mine);
let html = '';
if (myZeiten.length) {
html += `<div style="padding:var(--space-3) var(--space-4) var(--space-1);font-weight:600;font-size:var(--text-xs);color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:.05em">Meine Zeiten</div>`;
html += myZeiten.map(z => _gassiZeitCard(z)).join('');
}
if (andereZeiten.length) {
html += `<div style="padding:var(--space-3) var(--space-4) var(--space-1);font-weight:600;font-size:var(--text-xs);color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:.05em">In deiner Nähe</div>`;
html += andereZeiten.map(z => _gassiZeitCard(z)).join('');
}
el.innerHTML = html;
// Events
el.querySelectorAll('.gz-delete-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Gassi-Zeit löschen?')) return;
try {
await API.del(`gassi-zeiten/${btn.dataset.id}`);
_gassiZeiten = _gassiZeiten.filter(z => z.id !== parseInt(btn.dataset.id));
_renderGassiZeiten();
UI.toast.success('Gelöscht.');
} catch (e) { UI.toast.error(e.message || 'Fehler.'); }
});
});
el.querySelectorAll('.gz-toggle-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const gz = _gassiZeiten.find(z => z.id === parseInt(btn.dataset.id));
if (!gz) return;
try {
const updated = await API.patch(`gassi-zeiten/${gz.id}`, { aktiv: gz.aktiv ? 0 : 1 });
const idx = _gassiZeiten.findIndex(z => z.id === gz.id);
if (idx !== -1) _gassiZeiten[idx] = { ..._gassiZeiten[idx], aktiv: updated.aktiv };
_renderGassiZeiten();
} catch (e) { UI.toast.error(e.message || 'Fehler.'); }
});
});
el.querySelectorAll('.gz-chat-btn').forEach(btn => {
btn.addEventListener('click', () => {
const userId = parseInt(btn.dataset.userId);
App.navigate('chat', { user_id: userId });
});
});
}
function _gassiZeitCard(z) {
const wochentageLabel = (z.wochentage || []).join(', ').toUpperCase();
const distLabel = z.distance_m != null
? `<span style="color:var(--c-text-secondary);font-size:var(--text-xs)">${z.distance_m < 1000 ? z.distance_m + ' m' : (z.distance_m/1000).toFixed(1) + ' km'}</span>`
: '';
const pausedStyle = z.aktiv ? '' : 'opacity:.5';
return `
<div class="gassi-zeit-card" style="${pausedStyle}">
<div class="gz-avatar">
${z.dog_foto_url
? `<img src="${UI.escape(z.dog_foto_url)}" alt="${UI.escape(z.dog_name || '')}">`
: `<div class="gz-avatar-placeholder">${UI.icon('paw-print')}</div>`}
</div>
<div class="gz-body">
<div class="gz-name">${UI.escape(z.dog_name || z.user_name || '?')}
${z.dog_rasse ? `<span class="badge" style="font-size:var(--text-xs)">${UI.escape(z.dog_rasse)}</span>` : ''}
${!z.aktiv ? `<span class="badge badge-warning">Pausiert</span>` : ''}
</div>
<div class="gz-meta">
${UI.icon('clock')} ${UI.escape(z.uhrzeit)}
&nbsp;·&nbsp; ${wochentageLabel}
${z.ort_name ? `&nbsp;·&nbsp; ${UI.icon('map-pin')} ${UI.escape(z.ort_name)}` : ''}
${distLabel}
</div>
${z.notiz ? `<div class="gz-notiz">${UI.escape(z.notiz)}</div>` : ''}
</div>
<div class="gz-actions">
${z.is_mine ? `
<button class="btn btn-outline btn-xs gz-toggle-btn" data-id="${z.id}" title="${z.aktiv ? 'Pausieren' : 'Aktivieren'}">
${UI.icon(z.aktiv ? 'pause' : 'play')}
</button>
<button class="btn btn-outline btn-xs gz-delete-btn" data-id="${z.id}" title="Löschen">
${UI.icon('trash')}
</button>
` : `
<button class="btn btn-primary btn-xs gz-chat-btn" data-user-id="${z.user_id}" title="Chat öffnen">
${UI.icon('chat-circle')} Mitmachen
</button>
`}
</div>
</div>
`;
}
async function _showGassiZeitForm() {
const dogs = _appState.dogs || [];
const dogOptions = dogs.map(d => `<option value="${d.id}">${UI.escape(d.name)}</option>`).join('');
const wdBtns = _WOCHENTAGE.map(w =>
`<label class="wd-btn"><input type="checkbox" value="${w.key}"> ${w.label}</label>`
).join('');
UI.modal.open({
title: `${UI.icon('clock')} Stamm-Gassi-Zeit eintragen`,
body: `
<form id="gassi-zeit-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
${dogs.length ? `<div class="form-group">
<label>Hund</label>
<select id="gz-dog-select" style="width:100%">
<option value="">Kein Hund</option>
${dogOptions}
</select>
</div>` : ''}
<div class="form-group">
<label>Uhrzeit *</label>
<input type="time" id="gz-uhrzeit" required style="width:100%">
</div>
<div class="form-group">
<label>Wochentage *</label>
<div class="wd-selector">${wdBtns}</div>
</div>
<div class="form-group">
<label>Ort (optional)</label>
<input type="text" id="gz-ort-name" placeholder="z.B. Stadtpark Ebersberg" style="width:100%">
</div>
<div class="form-group">
<label>Notiz (optional)</label>
<input type="text" id="gz-notiz" placeholder="z.B. Wir sind eine ruhige Gruppe…" maxlength="200" style="width:100%">
</div>
</form>
`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="gz-save-btn">Speichern</button>
`,
});
document.getElementById('gz-save-btn').addEventListener('click', async () => {
const uhrzeit = document.getElementById('gz-uhrzeit')?.value;
if (!uhrzeit) { UI.toast.warning('Bitte Uhrzeit angeben.'); return; }
const wochentage = Array.from(document.querySelectorAll('.wd-btn input:checked')).map(cb => cb.value);
if (!wochentage.length) { UI.toast.warning('Bitte mindestens einen Wochentag wählen.'); return; }
const dogId = parseInt(document.getElementById('gz-dog-select')?.value) || null;
const ortName = document.getElementById('gz-ort-name')?.value?.trim() || null;
const notiz = document.getElementById('gz-notiz')?.value?.trim() || null;
const payload = { wochentage, uhrzeit, ort_name: ortName, notiz };
if (dogId) payload.dog_id = dogId;
if (_userPos) { payload.lat = _userPos.lat; payload.lon = _userPos.lon; }
try {
const created = await API.post('gassi-zeiten', payload);
_gassiZeiten.unshift({ ...created });
_renderGassiZeiten();
UI.toast.success('Gassi-Zeit eingetragen! 🐾');
UI.modal.close();
} catch (e) { UI.toast.error(e.message || 'Fehler beim Speichern.'); }
});
}
return { init, refresh, onDogChange, openNew, openDetail: _openDetail };
})();

View file

@ -10,9 +10,9 @@ const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
// index.html wird NICHT pre-gecacht (immer Network-First)
const STATIC_ASSETS = [
'/css/design-system.css?v=545',
'/css/layout.css?v=545',
'/css/components.css?v=545',
'/css/design-system.css?v=700',
'/css/layout.css?v=700',
'/css/components.css?v=700',
'/icons/phosphor.svg',
'/js/api.js',
'/js/ui.js',