Chore: Sprint32-36 Zwischenstand — alle Änderungen aus dieser Session committen

This commit is contained in:
rene 2026-05-03 11:09:39 +02:00
parent f4052fbb7d
commit 747c353444
20 changed files with 3115 additions and 63 deletions

View file

@ -1747,6 +1747,71 @@ def _migrate(conn_factory):
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS community_adoption (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL,
name TEXT NOT NULL,
rasse TEXT,
alter_jahre REAL,
geschlecht TEXT,
foto_url TEXT,
beschreibung TEXT NOT NULL,
gruende TEXT,
ort TEXT,
plz TEXT,
lat REAL,
lon REAL,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS community_adoption_interest (
id INTEGER PRIMARY KEY AUTOINCREMENT,
listing_id INTEGER NOT NULL REFERENCES community_adoption(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
nachricht TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(listing_id, user_id)
)
""")
# ---- Wetter-Log (historische Vorhersage-Daten) ----
conn.execute("""
CREATE TABLE IF NOT EXISTS weather_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
logged_at TEXT NOT NULL DEFAULT (datetime('now')),
date TEXT NOT NULL,
lat_r REAL NOT NULL,
lon_r REAL NOT NULL,
temp_max REAL,
temp_min REAL,
feels_max REAL,
precip_prob INTEGER,
precip_sum REAL,
wind_kmh REAL,
wind_dir TEXT,
uv_index REAL,
weathercode INTEGER,
weatherdesc TEXT,
sunrise TEXT,
sunset TEXT,
asphalt_temp REAL,
asphalt_warn TEXT,
zecken TEXT,
pollen_erle INTEGER,
pollen_birke INTEGER,
pollen_graeser INTEGER,
pollen_beifuss INTEGER,
pollen_ambrosia INTEGER,
forecast_json TEXT,
UNIQUE(date, lat_r, lon_r)
)
""")
# ---- Favoriten-Tierarzt + Gesundheitsdokumente ----
conn.execute("""
CREATE TABLE IF NOT EXISTS favorite_vets (

View file

@ -11,6 +11,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from brotli_asgi import BrotliMiddleware
from contextlib import asynccontextmanager
from database import init_db
@ -134,6 +135,7 @@ class MediaCacheMiddleware(BaseHTTPMiddleware):
return response
app.add_middleware(MediaCacheMiddleware)
app.add_middleware(BrotliMiddleware, minimum_size=1000, quality=4)
app.add_middleware(GZipMiddleware, minimum_size=1000)

View file

@ -15,3 +15,4 @@ odfpy==1.4.1
polyline==2.0.2
fpdf2==2.8.3
python-dateutil>=2.9
brotli-asgi==1.4.0

View file

@ -356,11 +356,15 @@ async def list_users(
# ------------------------------------------------------------------
@router.patch("/users/{uid}")
async def patch_user(uid: int, data: UserPatch, user=Depends(require_mod)):
# Rollenwechsel nur für Admins
# Rollenwechsel + Privileg-Flags nur für Admins
if data.rolle is not None and user["rolle"] != "admin":
raise HTTPException(403, "Rollenwechsel nur für Admins.")
if data.rolle and data.rolle not in ("user", "moderator", "admin"):
raise HTTPException(400, "Ungültige Rolle.")
if data.is_moderator is not None and user["rolle"] != "admin":
raise HTTPException(403, "is_moderator darf nur von Admins geändert werden.")
if data.is_social_media is not None and user["rolle"] != "admin":
raise HTTPException(403, "is_social_media darf nur von Admins geändert werden.")
with db() as conn:
target = conn.execute("SELECT id, rolle, name FROM users WHERE id=?", (uid,)).fetchone()

View file

@ -13,10 +13,17 @@ import os
import math
import logging
import asyncio
import uuid
import httpx
from datetime import datetime, timedelta
from fastapi import APIRouter, Query, BackgroundTasks
from fastapi import APIRouter, Query, BackgroundTasks, Depends, Form, UploadFile, File, HTTPException
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
from routes.push import send_push_to_user
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
logger = logging.getLogger(__name__)
router = APIRouter()
@ -290,3 +297,251 @@ async def adoption_geocode(plz: str = Query(..., min_length=4, max_length=10)):
except Exception as e:
logger.warning(f"Geocode PLZ {plz}: {e}")
return {"lat": None, "lon": None, "display": plz}
# ==================================================================
# Community Adoption — Privates Weitervermittlungs-Board
# ==================================================================
class InterestBody(BaseModel):
nachricht: Optional[str] = None
# ------------------------------------------------------------------
# GET /api/adoption/community/my — eigene Inserate
# ------------------------------------------------------------------
@router.get("/community/my")
def community_my(user=Depends(get_current_user)):
with db() as conn:
rows = conn.execute("""
SELECT ca.*,
u.name AS besitzer_name,
(SELECT COUNT(*) FROM community_adoption_interest i WHERE i.listing_id = ca.id) AS interest_count
FROM community_adoption ca
JOIN users u ON u.id = ca.user_id
WHERE ca.user_id = ? AND ca.status != 'deleted'
ORDER BY ca.created_at DESC
""", (user["id"],)).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# GET /api/adoption/community — alle aktiven Inserate (mit optionaler Nähe)
# ------------------------------------------------------------------
@router.get("/community")
def community_list(
lat: Optional[float] = Query(None),
lon: Optional[float] = Query(None),
radius: float = Query(200.0, description="Radius in km (default 200)"),
user=Depends(get_current_user),
):
with db() as conn:
rows = conn.execute("""
SELECT ca.*,
u.name AS besitzer_name,
(SELECT COUNT(*) FROM community_adoption_interest i WHERE i.listing_id = ca.id) AS interest_count,
(SELECT COUNT(*) FROM community_adoption_interest i2
WHERE i2.listing_id = ca.id AND i2.user_id = ?) AS _user_interested
FROM community_adoption ca
JOIN users u ON u.id = ca.user_id
WHERE ca.status = 'active'
ORDER BY ca.created_at DESC
LIMIT 50
""", (user["id"],)).fetchall()
result = []
for row in rows:
d = dict(row)
d["user_interested"] = bool(d.pop("_user_interested", 0))
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"])
d["distanz_km"] = round(dist, 1)
if dist > radius:
continue
else:
d["distanz_km"] = None
result.append(d)
if lat is not None and lon is not None:
result.sort(key=lambda x: x["distanz_km"] if x["distanz_km"] is not None else 9999)
return result
# ------------------------------------------------------------------
# POST /api/adoption/community — Inserat erstellen
# ------------------------------------------------------------------
@router.post("/community", status_code=201)
async def community_create(
name: str = Form(...),
beschreibung: str = Form(...),
rasse: str = Form(""),
alter_jahre: Optional[float] = Form(None),
geschlecht: str = Form(""),
gruende: str = Form(""),
ort: str = Form(""),
plz: str = Form(""),
lat: Optional[float] = Form(None),
lon: Optional[float] = Form(None),
dog_id: Optional[int] = Form(None),
foto: Optional[UploadFile] = File(None),
user=Depends(get_current_user),
):
foto_url = None
if foto and foto.filename:
MAX_SIZE = 5 * 1024 * 1024
header = await foto.read(12)
if len(header) < 3:
raise HTTPException(400, "Ungültige Datei")
is_jpeg = header[:3] == b"\xff\xd8\xff"
is_png = header[:4] == b"\x89PNG"
is_webp = header[:4] == b"RIFF" and len(header) >= 12 and header[8:12] == b"WEBP"
if not (is_jpeg or is_png or is_webp):
raise HTTPException(400, "Nur JPEG, PNG oder WebP erlaubt")
rest = await foto.read(MAX_SIZE)
if len(rest) >= MAX_SIZE:
raise HTTPException(400, "Foto zu groß (max 5 MB)")
data = header + rest
folder = os.path.join(MEDIA_DIR, "adoption")
os.makedirs(folder, exist_ok=True)
filename = f"{uuid.uuid4()}.jpg"
filepath = os.path.join(folder, filename)
with open(filepath, "wb") as f:
f.write(data)
foto_url = f"/media/adoption/{filename}"
with db() as conn:
cur = conn.execute("""
INSERT INTO community_adoption
(user_id, dog_id, name, rasse, alter_jahre, geschlecht,
foto_url, beschreibung, gruende, ort, plz, lat, lon)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
""", (
user["id"], dog_id, name, rasse or None, alter_jahre,
geschlecht or None, foto_url, beschreibung,
gruende or None, ort or None, plz or None, lat, lon,
))
new_id = cur.lastrowid
row = conn.execute(
"SELECT * FROM community_adoption WHERE id = ?", (new_id,)
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# PATCH /api/adoption/community/{id} — Status ändern (nur Besitzer)
# ------------------------------------------------------------------
class _StatusBody(BaseModel):
status: str
@router.patch("/community/{listing_id}")
def community_update_status(
listing_id: int,
body: _StatusBody,
user=Depends(get_current_user),
):
allowed = {"active", "reserved", "vermittelt"}
if body.status not in allowed:
raise HTTPException(400, f"Status muss einer von {allowed} sein")
status = body.status
with db() as conn:
cur = conn.execute("""
UPDATE community_adoption
SET status = ?, updated_at = datetime('now')
WHERE id = ? AND user_id = ?
""", (status, listing_id, user["id"]))
if cur.rowcount == 0:
raise HTTPException(404, "Inserat nicht gefunden oder kein Zugriff")
return {"ok": True}
# ------------------------------------------------------------------
# DELETE /api/adoption/community/{id} — Soft-Delete (nur Besitzer)
# ------------------------------------------------------------------
@router.delete("/community/{listing_id}")
def community_delete(listing_id: int, user=Depends(get_current_user)):
with db() as conn:
cur = conn.execute("""
UPDATE community_adoption
SET status = 'deleted', updated_at = datetime('now')
WHERE id = ? AND user_id = ?
""", (listing_id, user["id"]))
if cur.rowcount == 0:
raise HTTPException(404, "Inserat nicht gefunden oder kein Zugriff")
return {"ok": True}
# ------------------------------------------------------------------
# POST /api/adoption/community/{id}/interest — Interesse bekunden
# ------------------------------------------------------------------
@router.post("/community/{listing_id}/interest", status_code=201)
def community_interest(listing_id: int, body: InterestBody = None, user=Depends(get_current_user)):
nachricht = (body.nachricht if body else None) or None
with db() as conn:
listing = conn.execute(
"SELECT id, name, user_id FROM community_adoption WHERE id = ? AND status != 'deleted'",
(listing_id,)
).fetchone()
if not listing:
raise HTTPException(404, "Inserat nicht gefunden")
if listing["user_id"] == user["id"]:
raise HTTPException(400, "Eigenes Inserat")
try:
conn.execute("""
INSERT INTO community_adoption_interest (listing_id, user_id, nachricht)
VALUES (?, ?, ?)
""", (listing_id, user["id"], nachricht))
except Exception:
raise HTTPException(409, "Interesse bereits bekundet")
try:
send_push_to_user(listing["user_id"], {
"title": "Jemand interessiert sich für deinen Hund \U0001f43e",
"body": f"{user['name']} möchte mehr über {listing['name']} erfahren.",
"url": "/#adoption",
})
except Exception as e:
logger.warning(f"Push interest: {e}")
return {"ok": True}
# ------------------------------------------------------------------
# DELETE /api/adoption/community/{id}/interest — Interesse zurückziehen
# ------------------------------------------------------------------
@router.delete("/community/{listing_id}/interest")
def community_interest_withdraw(listing_id: int, user=Depends(get_current_user)):
with db() as conn:
cur = conn.execute("""
DELETE FROM community_adoption_interest
WHERE listing_id = ? AND user_id = ?
""", (listing_id, user["id"]))
if cur.rowcount == 0:
raise HTTPException(404, "Kein Interesse gefunden")
return {"ok": True}
# ------------------------------------------------------------------
# GET /api/adoption/community/{id}/interests — Interessenten (nur Besitzer)
# ------------------------------------------------------------------
@router.get("/community/{listing_id}/interests")
def community_interests(listing_id: int, user=Depends(get_current_user)):
with db() as conn:
listing = conn.execute(
"SELECT user_id FROM community_adoption WHERE id = ? AND status != 'deleted'",
(listing_id,)
).fetchone()
if not listing:
raise HTTPException(404, "Inserat nicht gefunden")
if listing["user_id"] != user["id"]:
raise HTTPException(403, "Nur der Besitzer kann Interessenten sehen")
rows = conn.execute("""
SELECT i.id, i.nachricht, i.created_at, u.id AS user_id, u.name, u.avatar_url
FROM community_adoption_interest i
JOIN users u ON u.id = i.user_id
WHERE i.listing_id = ?
ORDER BY i.created_at ASC
""", (listing_id,)).fetchall()
return [dict(r) for r in rows]

View file

@ -26,12 +26,14 @@ _SMTP_READY = bool(os.getenv("SMTP_SUPPORT_USER") and os.getenv("SMTP_SUPPORT_P
def _send_verification_email(email: str, name: str, token: str):
if not _SMTP_READY:
return
import html as _html
from routes.outreach import _send_smtp
from mailer import email_html
url = f"{_APP_URL}/api/auth/verify-email/{token}"
subject = "Ban Yaro — bitte bestätige deine E-Mail-Adresse"
_ename = _html.escape(name)
body_html = f"""
<p style="margin:0 0 16px">Hallo <b>{name}</b>,</p>
<p style="margin:0 0 16px">Hallo <b>{_ename}</b>,</p>
<p style="margin:0 0 16px">
willkommen bei Ban Yaro! Bitte bestätige deine E-Mail-Adresse, damit dein Konto aktiv wird.
</p>
@ -306,13 +308,15 @@ async def forgot_password(data: ForgotPasswordRequest, request: Request):
"UPDATE users SET password_reset_token=?, password_reset_expires=? WHERE id=?",
(token, expires, user["id"])
)
import html as _html
app_url = os.getenv("APP_URL", "https://banyaro.app")
url = f"{app_url}/#reset-password?token={token}"
subject = "Ban Yaro — Passwort zurücksetzen"
from routes.outreach import _send_smtp
from mailer import email_html
_ename = _html.escape(user['name'])
body_html = f"""
<p style="margin:0 0 16px">Hallo <b>{user['name']}</b>,</p>
<p style="margin:0 0 16px">Hallo <b>{_ename}</b>,</p>
<p style="margin:0 0 16px">
du hast eine Passwort-Zurücksetzen-Anfrage gestellt. Klicke auf den Button, um ein neues Passwort zu setzen.
</p>

View file

@ -315,11 +315,13 @@ async def update_dog(dog_id: int, data: DogUpdate, user=Depends(get_current_user
values = list(fields.values()) + [dog_id, user["id"]]
with db() as conn:
conn.execute(
updated = conn.execute(
f"UPDATE dogs SET {set_clause} WHERE id=? AND user_id=?", values
)
).rowcount
if not updated:
raise HTTPException(404, "Hund nicht gefunden.")
dog = conn.execute(
"SELECT * FROM dogs WHERE id=?", (dog_id,)
"SELECT * FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])
).fetchone()
return dict(dog)
@ -413,8 +415,8 @@ async def delete_photo(dog_id: int, user=Depends(get_current_user)):
os.remove(path)
with db() as conn:
conn.execute(
"UPDATE dogs SET foto_url=NULL, foto_zoom=1.0, foto_offset_x=0.0, foto_offset_y=0.0 WHERE id=?",
(dog_id,)
"UPDATE dogs SET foto_url=NULL, foto_zoom=1.0, foto_offset_x=0.0, foto_offset_y=0.0 WHERE id=? AND user_id=?",
(dog_id, user["id"])
)

View file

@ -641,7 +641,7 @@ async def resolve_report(report_id: int, data: ResolveReport, user=Depends(get_c
# GET /api/forum/members/map
# ------------------------------------------------------------------
@router.get("/members/map")
async def members_map():
async def members_map(user=Depends(get_current_user)):
with db() as conn:
rows = conn.execute(
"""SELECT SUBSTR(name, 1, INSTR(name || ' ', ' ') - 1) AS vorname,

View file

@ -1,5 +1,6 @@
"""BAN YARO — Social-Media-Job Bewerbungs-System"""
import html as _html
import os
import uuid
from datetime import datetime, timedelta
@ -98,8 +99,9 @@ async def apply(
# Bestätigungs-Mail an Bewerber
try:
_name = _html.escape(name)
body = f"""
<p style="margin:0 0 16px">Hallo <b>{name}</b>,</p>
<p style="margin:0 0 16px">Hallo <b>{_name}</b>,</p>
<p style="margin:0 0 16px">
deine Bewerbung als Social-Media-Manager/in bei Ban Yaro ist bei uns eingegangen.
Wir melden uns bald bei dir!
@ -110,7 +112,7 @@ async def apply(
email,
"Deine Bewerbung bei Ban Yaro 🐾",
email_html(body, cta_url="https://banyaro.app", cta_label="Zur App"),
f"Hallo {name}, deine Bewerbung ist eingegangen!",
f"Hallo {_name}, deine Bewerbung ist eingegangen!",
)
except Exception:
pass
@ -119,16 +121,22 @@ async def apply(
try:
admin_email = os.getenv("ADMIN_EMAIL", "")
if admin_email:
_ename = _html.escape(name)
_eemail = _html.escape(email)
_edog_name = _html.escape(dog_name)
_edog_rasse = _html.escape(dog_rasse)
_ehandle = _html.escape(social_handle)
_emotivation = _html.escape(motivation[:300])
admin_body = f"""
<p style="margin:0 0 12px"><b>Neue Job-Bewerbung eingegangen:</b></p>
<table style="font-size:14px;border-collapse:collapse;width:100%">
<tr><td style="padding:4px 12px 4px 0;color:#888">Name</td><td><b>{name}</b></td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#888">E-Mail</td><td>{email}</td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#888">Hund</td><td>{dog_name} ({dog_rasse})</td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#888">Social</td><td>{social_handle}</td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#888">Name</td><td><b>{_ename}</b></td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#888">E-Mail</td><td>{_eemail}</td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#888">Hund</td><td>{_edog_name} ({_edog_rasse})</td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#888">Social</td><td>{_ehandle}</td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#888">Anhänge</td><td>{len([f for f in files if f.filename])} Datei(en)</td></tr>
</table>
<p style="margin:12px 0 0;font-size:14px;color:#444">{motivation[:300]}{"" if len(motivation)>300 else ""}</p>"""
<p style="margin:12px 0 0;font-size:14px;color:#444">{_emotivation}{"" if len(motivation)>300 else ""}</p>"""
await send_email(
admin_email,
f"[Banyaro Jobs] Neue Bewerbung — {name}",
@ -293,16 +301,17 @@ async def download_doc(app_id: int, doc_id: int, admin=Depends(require_admin)):
def _send_status_mail(email: str, name: str, status: str, note: str):
import asyncio
_ename = _html.escape(name)
texts = {
"reviewing": ("Wir schauen uns deine Bewerbung genauer an 🐾",
f"<p>Hallo <b>{name}</b>,</p><p>wir schauen uns deine Bewerbung gerade genauer an. Wir melden uns bald!</p>"),
f"<p>Hallo <b>{_ename}</b>,</p><p>wir schauen uns deine Bewerbung gerade genauer an. Wir melden uns bald!</p>"),
"accepted": ("Herzlichen Glückwunsch — du bist dabei! 🎉",
f"<p>Hallo <b>{name}</b>,</p><p>wir freuen uns, dir mitzuteilen: <b>du bist unser neuer Social-Media-Manager/in für Ban Yaro!</b><br>Du erhältst außerdem den <b>Gründer-Status</b> in unserer Community. Willkommen im Team!</p>"),
f"<p>Hallo <b>{_ename}</b>,</p><p>wir freuen uns, dir mitzuteilen: <b>du bist unser neuer Social-Media-Manager/in für Ban Yaro!</b><br>Du erhältst außerdem den <b>Gründer-Status</b> in unserer Community. Willkommen im Team!</p>"),
"rejected": ("Deine Bewerbung bei Ban Yaro",
f"<p>Hallo <b>{name}</b>,</p><p>vielen Dank für deine Bewerbung. Leider hat es diesmal nicht geklappt — aber wir wünschen dir alles Gute!</p>"),
f"<p>Hallo <b>{_ename}</b>,</p><p>vielen Dank für deine Bewerbung. Leider hat es diesmal nicht geklappt — aber wir wünschen dir alles Gute!</p>"),
}
subj, body_start = texts.get(status, ("Update zu deiner Bewerbung", f"<p>Hallo {name},</p>"))
note_html = f'<div style="background:#fdf6ef;border-left:3px solid #C4843A;padding:12px 16px;border-radius:0 8px 8px 0;margin:12px 0">{note}</div>' if note else ""
subj, body_start = texts.get(status, ("Update zu deiner Bewerbung", f"<p>Hallo {_ename},</p>"))
note_html = f'<div style="background:#fdf6ef;border-left:3px solid #C4843A;padding:12px 16px;border-radius:0 8px 8px 0;margin:12px 0">{_html.escape(note)}</div>' if note else ""
body = body_start + note_html
async def _send():

View file

@ -265,13 +265,14 @@ async def welfare_confirm(litter_id: int, user=Depends(_require_breeder)):
eltern = conn.execute(
"SELECT vater_name, mutter_name FROM litters WHERE id=?", (litter_id,)
).fetchone()
import html as _html
welfare_body = f"""
<p style="margin:0 0 12px"><b>Kritischer Tierschutz-Hinweis bestätigt</b></p>
<table style="font-size:14px;border-collapse:collapse;width:100%">
<tr><td style="padding:5px 12px 5px 0;color:#888;white-space:nowrap">Züchter</td><td style="padding:5px 0"><b>{zuechter}</b></td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">Zwinger</td><td style="padding:5px 0">{zwinger}</td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">Vater</td><td style="padding:5px 0">{eltern['vater_name'] or ''}</td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">Mutter</td><td style="padding:5px 0">{eltern['mutter_name'] or ''}</td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888;white-space:nowrap">Züchter</td><td style="padding:5px 0"><b>{_html.escape(zuechter)}</b></td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">Zwinger</td><td style="padding:5px 0">{_html.escape(zwinger)}</td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">Vater</td><td style="padding:5px 0">{_html.escape(eltern['vater_name'] or '')}</td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">Mutter</td><td style="padding:5px 0">{_html.escape(eltern['mutter_name'] or '')}</td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">Wurf-ID</td><td style="padding:5px 0">#{litter_id}</td></tr>
</table>"""
try:

View file

@ -268,6 +268,9 @@ async def mod_poi_edit_action(edit_id: int, data: dict,
raise HTTPException(409, "Korrektur wurde bereits bearbeitet.")
if action == "approve":
_ALLOWED_POI_FIELDS = {"opening_hours", "phone", "website", "name"}
if edit["field"] not in _ALLOWED_POI_FIELDS:
raise HTTPException(400, f"Ungültiges Feld: {edit['field']}")
conn.execute(
f"UPDATE osm_pois SET {edit['field']}=?, user_edited=1 WHERE osm_id=?",
(edit["new_value"], edit["osm_id"])

View file

@ -29,7 +29,7 @@ async def get_leaderboard(user=Depends(get_current_user)):
JOIN dogs d ON d.id = ts.dog_id
JOIN users u ON u.id = ts.user_id
WHERE ts.current_streak > 0
AND (d.is_public = 1 OR d.user_id = ts.user_id)
AND d.is_public = 1
ORDER BY ts.current_streak DESC
LIMIT 10
""").fetchall()

View file

@ -3,8 +3,9 @@ BAN YARO — Wetter-API
GET /api/weather?lat=&lon= aktuelles Wetter + Zecken-Warnung für Nutzerstandort
"""
from fastapi import APIRouter, Query, HTTPException
from fastapi import APIRouter, Query, HTTPException, Depends
import weather as weather_module
from auth import get_current_user
router = APIRouter()
@ -18,3 +19,15 @@ async def get_weather(
return await weather_module.get_weather_for_location(lat, lon)
except Exception as exc:
raise HTTPException(503, f'Wetter nicht verfügbar: {exc}')
@router.get('/forecast')
async def get_weather_forecast(
lat: float = Query(..., ge=-90, le=90),
lon: float = Query(..., ge=-180, le=180),
user=Depends(get_current_user),
):
try:
return await weather_module.get_forecast(lat, lon)
except Exception as exc:
raise HTTPException(503, f'Wettervorhersage nicht verfügbar: {exc}')

View file

@ -317,19 +317,24 @@ async def submit_foto(
if not rights_confirmed:
raise HTTPException(400, "Bildrechte-Bestätigung fehlt.")
# Dateiformat prüfen
ct = file.content_type or ""
if not ct.startswith("image/"):
raise HTTPException(400, "Nur Bilddateien erlaubt.")
_IMAGE_MAGIC = [
b"\xff\xd8\xff", # JPEG
b"\x89PNG\r\n\x1a\n", # PNG
b"RIFF", # WebP (RIFF....WEBP)
b"GIF87a", b"GIF89a", # GIF
]
os.makedirs(SUBMIT_DIR, exist_ok=True)
ts = int(time.time())
filename = f"{slug}_{user['id']}_{ts}.jpg"
path = os.path.join(SUBMIT_DIR, filename)
ts = int(time.time())
content = await file.read()
if len(content) > 8 * 1024 * 1024:
raise HTTPException(400, "Datei zu groß (max. 8 MB).")
if not any(content.startswith(magic) for magic in _IMAGE_MAGIC):
raise HTTPException(400, "Nur Bilddateien erlaubt (JPEG, PNG, WebP, GIF).")
filename = f"{slug}_{user['id']}_{ts}.jpg"
path = os.path.join(SUBMIT_DIR, filename)
with open(path, "wb") as f:
f.write(content)

File diff suppressed because it is too large Load diff

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 691 KiB

Before After
Before After

View file

@ -6,69 +6,84 @@
const API = (() => {
// ----------------------------------------------------------
// Request-Deduplication: gleiche GET-URL nur einmal in-flight
// ----------------------------------------------------------
const _inflight = new Map();
// ----------------------------------------------------------
// Interner HTTP-Kern
// ----------------------------------------------------------
async function _request(method, path, body = null, options = {}) {
async function _doRequest(method, path, body, attempt) {
const config = {
method,
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // HttpOnly Cookie wird automatisch mitgesendet
credentials: 'include',
};
if (body && !(body instanceof FormData)) {
config.body = JSON.stringify(body);
} else if (body instanceof FormData) {
delete config.headers['Content-Type']; // Browser setzt multipart/form-data
delete config.headers['Content-Type'];
config.body = body;
}
// JWT aus localStorage als Bearer (für API-Calls die das brauchen)
const token = localStorage.getItem('by_token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
if (token) config.headers['Authorization'] = `Bearer ${token}`;
let response;
try {
response = await fetch(`/api${path}`, config);
} catch (err) {
const offlineMsg = 'Kein Internet — du bist offline.';
if (window.UI && UI.toast) UI.toast.warning(offlineMsg, 4000);
throw new APIError(offlineMsg, 0, 'network');
} catch {
// Netzwerkfehler: bei GET bis zu 2 Retry-Versuche
if (method === 'GET' && attempt < 2) {
await new Promise(r => setTimeout(r, 200 * Math.pow(3, attempt)));
return _doRequest(method, path, body, attempt + 1);
}
const msg = 'Kein Internet — du bist offline.';
if (window.UI?.toast) UI.toast.warning(msg, 4000);
throw new APIError(msg, 0, 'network');
}
// 204 No Content
if (response.status === 204) return null;
let data;
try {
data = await response.json();
} catch {
data = null;
}
try { data = await response.json(); } catch { data = null; }
if (!response.ok) {
const message = data?.detail || data?.message || `Fehler ${response.status}`;
// SW gibt bei Offline-Anfragen 503 + 'Offline — keine Verbindung.' zurück
const isOffline = response.status === 503 && message.startsWith('Offline');
if (isOffline && window.UI && UI.toast) {
UI.toast.warning('Kein Internet — du bist offline.', 4000);
const isSwOffline = response.status === 503 && message.startsWith('Offline');
// Retry: GET auf echte 5xx (nicht SW-generierte Offline-503)
if (method === 'GET' && response.status >= 500 && !isSwOffline && attempt < 2) {
await new Promise(r => setTimeout(r, 200 * Math.pow(3, attempt)));
return _doRequest(method, path, body, attempt + 1);
}
throw new APIError(message, response.status, isOffline ? 'network' : data?.code);
if (isSwOffline && window.UI?.toast) UI.toast.warning('Kein Internet — du bist offline.', 4000);
throw new APIError(message, response.status, isSwOffline ? 'network' : data?.code);
}
// SW hat die Anfrage in die Offline-Queue eingereiht
if (data?._queued) {
if (typeof UI !== 'undefined' && UI.toast) {
if (typeof UI !== 'undefined' && UI.toast)
UI.toast.info('Offline gespeichert — wird automatisch synchronisiert');
}
return data;
}
return data;
}
async function _request(method, path, body = null) {
// GET-Deduplication: laufende identische Anfragen zusammenfassen
if (method === 'GET') {
if (_inflight.has(path)) return _inflight.get(path);
const promise = _doRequest('GET', path, null, 0).finally(() => _inflight.delete(path));
_inflight.set(path, promise);
return promise;
}
return _doRequest(method, path, body, 0);
}
// ----------------------------------------------------------
// Öffentliche HTTP-Methoden
// ----------------------------------------------------------
@ -426,8 +441,9 @@ const API = (() => {
// WETTER
// ----------------------------------------------------------
const weather = {
alerts(lat, lon) { return get(`/weather/alerts?lat=${lat}&lon=${lon}`); },
get(lat, lon) { return get(`/weather?lat=${lat}&lon=${lon}`); },
alerts(lat, lon) { return get(`/weather/alerts?lat=${lat}&lon=${lon}`); },
get(lat, lon) { return get(`/weather?lat=${lat}&lon=${lon}`); },
forecast(lat, lon) { return get(`/weather/forecast?lat=${lat}&lon=${lon}`); },
};
// ----------------------------------------------------------

View file

@ -17,6 +17,8 @@ window.Page_adoption = (() => {
let _activeTab = 'hunde';
let _data = null; // { animals, shelters, has_petfinder }
let _loading = false;
let _communityData = null; // [] listings from /adoption/community
let _myListings = null; // [] eigene Inserate
// ----------------------------------------------------------
// INIT
@ -90,6 +92,12 @@ window.Page_adoption = (() => {
border-bottom:2px solid transparent;font-size:var(--text-sm)">
${UI.icon('house-line')} Tierheime
</button>
<button class="adp-tab" data-tab="community"
style="padding:var(--space-2) var(--space-3);background:none;border:none;
cursor:pointer;color:var(--c-text-secondary);
border-bottom:2px solid transparent;font-size:var(--text-sm)">
${UI.icon('heart')} Weitervermittlung
</button>
</div>
<!-- Inhalt -->
@ -213,12 +221,43 @@ window.Page_adoption = (() => {
}
}
async function _loadCommunity() {
const content = _container.querySelector('#adp-content');
if (content) content.innerHTML = UI.skeleton(4);
try {
const url = _lat && _lon
? `/adoption/community?lat=${_lat}&lon=${_lon}`
: '/adoption/community';
_communityData = await API.get(url);
if (_appState?.user) {
try {
_myListings = await API.get('/adoption/community/my');
} catch {
_myListings = [];
}
}
_renderCommunity(content);
} catch {
if (content) content.innerHTML = UI.emptyState({
icon: 'warning',
title: 'Weitervermittlungs-Inserate konnten nicht geladen werden',
text: 'Bitte versuche es erneut.',
});
}
}
// ----------------------------------------------------------
// INHALT RENDERN (je nach Tab)
// ----------------------------------------------------------
function _renderContent() {
const content = _container.querySelector('#adp-content');
if (!content) return;
if (_activeTab === 'community') {
_loadCommunity();
return;
}
if (!_data) { _showNoLocation(); return; }
if (_activeTab === 'hunde') _renderHunde(content);
@ -455,6 +494,442 @@ window.Page_adoption = (() => {
`;
}
// ------------------------------------------------------------------
// TAB: WEITERVERMITTLUNG (Community)
// ------------------------------------------------------------------
function _renderCommunity(content) {
if (!content) return;
const listings = _communityData || [];
const isLoggedIn = !!_appState?.user;
const fabHtml = isLoggedIn ? `
<button id="adp-fab-create"
style="position:fixed;bottom:calc(var(--nav-height,64px) + var(--space-4));right:var(--space-4);
z-index:100;width:56px;height:56px;border-radius:50%;
background:var(--c-primary);color:#fff;border:none;cursor:pointer;
box-shadow:0 4px 16px rgba(0,0,0,0.2);
display:flex;align-items:center;justify-content:center;font-size:1.5rem"
title="Hund zur Vermittlung anbieten"
aria-label="Hund zur Vermittlung anbieten">
${UI.icon('plus')}
</button>
` : '';
if (!listings.length) {
content.innerHTML = `
<div style="text-align:center;padding:var(--space-8) var(--space-4)">
<div style="font-size:2.5rem;margin-bottom:var(--space-3)">🐾</div>
<h3 style="margin-bottom:var(--space-2)">Noch keine Hunde zur Weitervermittlung</h3>
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-5);max-width:320px;margin-inline:auto">
Hier können Halter Hunde privat zur Weitervermittlung anbieten
zum Beispiel bei Umzug, Krankheit oder Allergie.
</p>
${isLoggedIn ? `
<button class="btn btn-primary" id="adp-empty-create">
${UI.icon('plus')} Hund zur Vermittlung anbieten
</button>
` : `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary)">
Bitte anmelden, um ein Inserat zu erstellen.
</p>
`}
</div>
${fabHtml}
`;
content.querySelector('#adp-empty-create')?.addEventListener('click', _openCreateModal);
content.querySelector('#adp-fab-create')?.addEventListener('click', _openCreateModal);
return;
}
// Eigene Inserate trennen
const myIds = new Set((_myListings || []).map(l => l.id));
content.innerHTML = `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
${listings.length} Inserat${listings.length !== 1 ? 'e' : ''} zur Weitervermittlung
</p>
<div class="adp-grid"
style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:var(--space-3)">
${listings.map(l => _communityCard(l)).join('')}
</div>
${isLoggedIn && _myListings && _myListings.length ? `
<div id="adp-my-listings" style="margin-top:var(--space-6);padding-top:var(--space-4);border-top:1px solid var(--c-border)">
<h4 style="margin-bottom:var(--space-3)">Meine Inserate</h4>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${_myListings.map(l => _myListingRow(l)).join('')}
</div>
</div>
` : ''}
${fabHtml}
`;
// Interest-Button Events
content.querySelectorAll('[data-adp-interest]').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.dataset.adpInterest;
const interested = btn.dataset.adpInterested === 'true';
_handleInterest(id, interested, btn);
});
});
// FAB
content.querySelector('#adp-fab-create')?.addEventListener('click', _openCreateModal);
// Meine Inserate: Status-Dropdown + Löschen
content.querySelectorAll('[data-adp-status-change]').forEach(sel => {
sel.addEventListener('change', async () => {
const id = sel.dataset.adpStatusChange;
try {
await API.patch(`/adoption/community/${id}`, { status: sel.value });
UI.toast.success('Status aktualisiert.');
_loadCommunity();
} catch {
UI.toast.error('Status konnte nicht aktualisiert werden.');
}
});
});
content.querySelectorAll('[data-adp-delete]').forEach(btn => {
btn.addEventListener('click', async () => {
if (!window.confirm('Inserat wirklich löschen?')) return;
try {
await API.del(`/adoption/community/${btn.dataset.adpDelete}`);
UI.toast.success('Inserat gelöscht.');
_communityData = null;
_myListings = null;
_loadCommunity();
} catch {
UI.toast.error('Löschen fehlgeschlagen.');
}
});
});
}
function _communityCard(l) {
const foto = l.foto_url
? `<img src="${_esc(l.foto_url)}" alt="${_esc(l.name)}"
style="width:100%;height:100%;object-fit:cover"
onerror="this.parentElement.innerHTML='<div style=&quot;display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem&quot;>🐾</div>'">`
: '<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem">🐾</div>';
const isActive = !l.status || l.status === 'active';
const statusLabel = l.status === 'reserved' ? 'Reserviert'
: l.status === 'adopted' ? 'Vermittelt'
: '';
const alterLabel = l.alter_kategorie === 'welpe' ? 'Welpe <6Mo'
: l.alter_kategorie === 'jung' ? 'Jung 6Mo2J'
: l.alter_kategorie === 'adult' ? 'Adult 28J'
: l.alter_kategorie === 'senior' ? 'Senior >8J'
: '';
const genderIcon = l.geschlecht === 'maennlich' ? '♂'
: l.geschlecht === 'weiblich' ? '♀'
: '';
const distTxt = l.distanz_km != null ? `${l.distanz_km} km` : '';
const ort = [l.plz, l.ort].filter(Boolean).join(' ');
const interestBtn = l.user_interested
? `<button class="btn btn-secondary btn-sm" style="width:100%;font-size:var(--text-xs)"
data-adp-interest="${_esc(l.id)}" data-adp-interested="true">
Bereits gemeldet
</button>`
: `<button class="btn btn-primary btn-sm" style="width:100%;font-size:var(--text-xs)"
data-adp-interest="${_esc(l.id)}" data-adp-interested="false"
${!isActive ? 'disabled' : ''}>
Interesse bekunden
</button>`;
return `
<div style="border-radius:var(--radius-md);overflow:hidden;
background:var(--c-bg-card,var(--c-surface-2));
box-shadow:0 1px 4px rgba(0,0,0,0.08);
display:flex;flex-direction:column;position:relative">
<!-- Foto -->
<div style="height:140px;overflow:hidden;background:var(--c-surface-3);position:relative">
${foto}
${!isActive ? `
<div style="position:absolute;inset:0;background:rgba(0,0,0,0.45);
display:flex;align-items:center;justify-content:center">
<span style="color:#fff;font-weight:700;font-size:var(--text-sm);
background:rgba(0,0,0,0.6);padding:4px 12px;border-radius:999px">
${_esc(statusLabel)}
</span>
</div>
` : ''}
</div>
<!-- Body -->
<div style="padding:var(--space-2) var(--space-2) var(--space-3);flex:1;display:flex;flex-direction:column;gap:var(--space-1)">
<div style="font-weight:600;font-size:var(--text-sm);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(l.name)}
</div>
${l.rasse ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(l.rasse)}
</div>` : ''}
<!-- Badges -->
<div style="display:flex;gap:4px;flex-wrap:wrap">
${alterLabel ? `<span style="font-size:10px;background:var(--c-surface-3);
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
${_esc(alterLabel)}
</span>` : ''}
${genderIcon ? `<span style="font-size:10px;background:var(--c-surface-3);
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
${genderIcon}
</span>` : ''}
${distTxt ? `<span style="font-size:10px;background:var(--c-primary-light,#ede9fe);
border-radius:999px;padding:1px 6px;color:var(--c-primary)">
${_esc(distTxt)}
</span>` : ''}
</div>
${ort ? `<div style="font-size:10px;color:var(--c-text-muted)">${_esc(ort)}</div>` : ''}
${l.beschreibung ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
overflow:hidden;display:-webkit-box;
-webkit-line-clamp:2;-webkit-box-orient:vertical">
${_esc(l.beschreibung)}
</div>` : ''}
${l.interesse_count ? `<div style="font-size:10px;color:var(--c-text-muted)">
${l.interesse_count} Interessent${l.interesse_count !== 1 ? 'en' : ''}
</div>` : ''}
<div style="margin-top:auto;padding-top:var(--space-1)">
${interestBtn}
</div>
</div>
</div>
`;
}
function _myListingRow(l) {
const statusOptions = [
{ value: 'active', label: 'Aktiv' },
{ value: 'reserved', label: 'Reserviert' },
{ value: 'adopted', label: 'Vermittelt' },
];
return `
<div style="display:flex;align-items:center;gap:var(--space-2);
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
background:var(--c-surface-2);border:1px solid var(--c-border)">
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:var(--text-sm);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(l.name)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
${l.interesse_count || 0} Interessent${(l.interesse_count || 0) !== 1 ? 'en' : ''}
</div>
</div>
<select class="form-control" style="width:auto;font-size:var(--text-xs)"
data-adp-status-change="${_esc(l.id)}">
${statusOptions.map(o => `
<option value="${o.value}" ${l.status === o.value ? 'selected' : ''}>${o.label}</option>
`).join('')}
</select>
<button class="btn btn-danger btn-sm" style="font-size:var(--text-xs);white-space:nowrap"
data-adp-delete="${_esc(l.id)}">
${UI.icon('trash')} Löschen
</button>
</div>
`;
}
// ------------------------------------------------------------------
// INTERESSE BEKUNDEN / ZURÜCKZIEHEN
// ------------------------------------------------------------------
async function _handleInterest(id, isInterested, btn) {
if (!_appState?.user) {
UI.toast.error('Bitte anmelden um Interesse zu bekunden.');
return;
}
if (isInterested) {
// Interesse zurückziehen
try {
btn.disabled = true;
await API.del(`/adoption/community/${id}/interest`);
UI.toast.success('Interesse zurückgezogen.');
_communityData = null;
_myListings = null;
_loadCommunity();
} catch {
UI.toast.error('Fehler beim Zurückziehen des Interesses.');
btn.disabled = false;
}
return;
}
// Interesse bekunden — Modal mit optionaler Nachricht
const body = `
<form id="adp-interest-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<p style="color:var(--c-text-secondary);font-size:var(--text-sm)">
Du kannst optional eine Nachricht an den Anbieter schicken.
</p>
<div class="form-group">
<label class="form-label">Nachricht (optional)</label>
<textarea class="form-control" name="nachricht" rows="3"
placeholder="Stell dich kurz vor und erzähle, warum dieser Hund zu dir passt…"></textarea>
</div>
</form>
`;
const footer = `
<div style="display:flex;gap:var(--space-2);width:100%">
<button type="submit" form="adp-interest-form" class="btn btn-primary flex-1" id="adp-interest-submit">
${UI.icon('heart')} Interesse bekunden
</button>
<button type="button" class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
</div>
`;
UI.modal.open({ title: 'Interesse bekunden', body, footer });
document.getElementById('adp-interest-form')?.addEventListener('submit', async e => {
e.preventDefault();
const submitBtn = document.getElementById('adp-interest-submit');
const fd = new FormData(e.target);
const payload = { nachricht: fd.get('nachricht') || null };
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '…'; }
try {
await API.post(`/adoption/community/${id}/interest`, payload);
UI.modal.close();
UI.toast.success('Interesse gemeldet!');
_communityData = null;
_myListings = null;
_loadCommunity();
} catch {
UI.toast.error('Fehler beim Melden des Interesses.');
if (submitBtn) { submitBtn.disabled = false; submitBtn.innerHTML = `${UI.icon('heart')} Interesse bekunden`; }
}
});
}
// ------------------------------------------------------------------
// INSERAT ERSTELLEN — Modal
// ------------------------------------------------------------------
function _openCreateModal() {
if (!_appState?.user) {
UI.toast.error('Bitte anmelden um ein Inserat zu erstellen.');
return;
}
const body = `
<form id="adp-create-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Name <span style="color:var(--c-danger)">*</span></label>
<input class="form-control" name="name" required placeholder="z.B. Bello">
</div>
<div class="form-group">
<label class="form-label">Rasse (optional)</label>
<input class="form-control" name="rasse" placeholder="z.B. Labrador Mischling">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2)">
<div class="form-group">
<label class="form-label">Alter</label>
<select class="form-control" name="alter_kategorie">
<option value="">Unbekannt</option>
<option value="welpe">Welpe &lt;6Mo</option>
<option value="jung">Jung 6Mo2J</option>
<option value="adult">Adult 28J</option>
<option value="senior">Senior &gt;8J</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Geschlecht</label>
<select class="form-control" name="geschlecht">
<option value="">Unbekannt</option>
<option value="maennlich">Männlich</option>
<option value="weiblich">Weiblich</option>
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2)">
<div class="form-group">
<label class="form-label">PLZ</label>
<input class="form-control" name="plz" inputmode="numeric" maxlength="5"
placeholder="z.B. 80331" value="${_esc(_lat ? '' : '')}">
</div>
<div class="form-group">
<label class="form-label">Ort</label>
<input class="form-control" name="ort" placeholder="z.B. München">
</div>
</div>
<div class="form-group">
<label class="form-label">Beschreibung <span style="color:var(--c-danger)">*</span></label>
<textarea class="form-control" name="beschreibung" rows="4" required minlength="80"
placeholder="Erzähle, warum du deinen Hund abgeben musst, und was ihn besonders macht…"></textarea>
<div style="font-size:10px;color:var(--c-text-muted);margin-top:2px">Mindestens 80 Zeichen</div>
</div>
<div class="form-group">
<label class="form-label">Hintergrund (optional)</label>
<textarea class="form-control" name="hintergrund" rows="2"
placeholder="Warum suchst du ein neues Zuhause? (Krankheit, Umzug, Allergie…)"></textarea>
</div>
<div class="form-group">
<label class="form-label">Foto (optional)</label>
<input class="form-control" type="file" name="foto" accept="image/*" id="adp-create-foto">
</div>
</form>
`;
const footer = `
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button type="submit" form="adp-create-form" class="btn btn-primary" style="width:100%" id="adp-create-submit">
${UI.icon('plus')} Inserat erstellen
</button>
<button type="button" class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
</div>
`;
UI.modal.open({ title: 'Hund zur Vermittlung anbieten', body, footer });
document.getElementById('adp-create-form')?.addEventListener('submit', async e => {
e.preventDefault();
const submitBtn = document.getElementById('adp-create-submit');
const fd = new FormData(e.target);
// Mindestlänge Beschreibung manuell prüfen (minlength gilt nur für text)
const beschreibung = (fd.get('beschreibung') || '').trim();
if (beschreibung.length < 80) {
UI.toast.error('Beschreibung muss mindestens 80 Zeichen lang sein.');
return;
}
// FormData für multipart aufbauen
const postData = new FormData();
postData.append('name', fd.get('name') || '');
postData.append('rasse', fd.get('rasse') || '');
postData.append('alter_kategorie', fd.get('alter_kategorie') || '');
postData.append('geschlecht', fd.get('geschlecht') || '');
postData.append('plz', fd.get('plz') || '');
postData.append('ort', fd.get('ort') || '');
postData.append('beschreibung', beschreibung);
postData.append('hintergrund', fd.get('hintergrund') || '');
if (_lat) postData.append('lat', _lat);
if (_lon) postData.append('lon', _lon);
const fotoFile = document.getElementById('adp-create-foto')?.files?.[0];
if (fotoFile) postData.append('foto', fotoFile);
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '…'; }
try {
await API.upload('/adoption/community', postData);
UI.modal.close();
UI.toast.success('Inserat erstellt!');
_communityData = null;
_myListings = null;
_loadCommunity();
} catch (err) {
UI.toast.error(err.message || 'Inserat konnte nicht erstellt werden.');
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.innerHTML = `${UI.icon('plus')} Inserat erstellen`;
}
}
});
}
// ----------------------------------------------------------
// HILFSFUNKTIONEN
// ----------------------------------------------------------

View file

@ -263,6 +263,12 @@ window.Page_settings = (() => {
<span>Kalender abonnieren</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
<div class="sidebar-item" id="settings-worlds-btn"
style="padding:var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border);cursor:pointer">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#squares-four"></use></svg>
<span>Welten einrichten</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
<div class="sidebar-item" id="settings-logout-btn"
style="padding:var(--space-4);border-radius:0;cursor:pointer;
color:var(--c-danger)">
@ -653,6 +659,11 @@ window.Page_settings = (() => {
}
});
document.getElementById('settings-worlds-btn')?.addEventListener('click', () => {
if (window.Worlds?._openConfigModal) window.Worlds._openConfigModal();
else if (window.Worlds) window.Worlds.openConfig?.();
});
document.getElementById('settings-logout-btn')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title : 'Abmelden?',

View file

@ -0,0 +1,581 @@
/* ============================================================
BAN YARO Wetter (7-Tage-Wettervorhersage)
Seiten-Modul: Hunde-optimierte Wettervorhersage mit GPS.
============================================================ */
window.Page_wetter = (() => {
// ----------------------------------------------------------
// KONSTANTEN
// ----------------------------------------------------------
// WMO-Code → Phosphor-Icon-Name (aus Sprite)
const WMO_ICON = {
0:'sun', 1:'sun-dim', 2:'cloud-sun', 3:'cloud',
45:'cloud-fog', 48:'cloud-fog',
51:'cloud-rain', 53:'cloud-rain', 55:'cloud-rain',
61:'cloud-rain', 63:'cloud-rain', 65:'cloud-rain',
71:'cloud-snow', 73:'cloud-snow', 75:'cloud-snow', 77:'snowflake',
80:'rainbow-cloud', 81:'cloud-rain', 82:'cloud-rain',
85:'cloud-snow', 86:'cloud-snow',
95:'cloud-lightning', 96:'cloud-lightning', 99:'cloud-lightning',
};
// Farben passend zum Wetter (für Icon-Tinting)
const WMO_COLOR = {
0:'#F59E0B', 1:'#F59E0B', 2:'#94A3B8', 3:'#64748B',
45:'#94A3B8', 48:'#94A3B8',
51:'#60A5FA', 53:'#3B82F6', 55:'#2563EB',
61:'#3B82F6', 63:'#2563EB', 65:'#1D4ED8',
71:'#BAE6FD', 73:'#7DD3FC', 75:'#38BDF8', 77:'#BAE6FD',
80:'#60A5FA', 81:'#3B82F6', 82:'#2563EB',
85:'#7DD3FC', 86:'#38BDF8',
95:'#7C3AED', 96:'#6D28D9', 99:'#5B21B6',
};
function _wmoIcon(code, size = '2rem', extraStyle = '') {
const name = WMO_ICON[code] || 'cloud';
const color = WMO_COLOR[code] || 'var(--c-text-secondary)';
return `<svg class="ph-icon" aria-hidden="true"
style="width:${size};height:${size};color:${color};flex-shrink:0;${extraStyle}">
<use href="/icons/phosphor.svg#${name}"></use>
</svg>`;
}
const WMO_DESC = {
0:'Klarer Himmel', 1:'Überwiegend klar', 2:'Teilweise bewölkt', 3:'Bedeckt',
45:'Nebel', 48:'Gefrierender Nebel',
51:'Leichter Sprühregen', 53:'Mäßiger Sprühregen', 55:'Starker Sprühregen',
61:'Leichter Regen', 63:'Mäßiger Regen', 65:'Starker Regen',
71:'Leichter Schneefall', 73:'Mäßiger Schneefall', 75:'Starker Schneefall', 77:'Schneekörner',
80:'Leichte Regenschauer', 81:'Mäßige Regenschauer', 82:'Starke Regenschauer',
85:'Leichte Schneeschauer', 86:'Starke Schneeschauer',
95:'Gewitter', 96:'Gewitter mit leichtem Hagel', 99:'Gewitter mit starkem Hagel'
};
const DAY_NAMES = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
// ----------------------------------------------------------
// MODUL-STATE
// ----------------------------------------------------------
let _container = null;
let _appState = null;
let _data = null;
let _selDay = 0;
let _loading = false;
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_selDay = 0;
_renderShell();
_tryAutoLocate();
}
// ----------------------------------------------------------
// REFRESH
// ----------------------------------------------------------
async function refresh() {
_selDay = 0;
_renderShell();
_tryAutoLocate();
}
// ----------------------------------------------------------
// RENDER — Grundstruktur
// ----------------------------------------------------------
function _renderShell() {
_container.innerHTML = `
<div id="wttr-body">
<div id="wttr-locating" style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="margin-bottom:var(--space-3)">${_wmoIcon(2, '2.5rem')}</div>
<p style="color:var(--c-text-secondary)">Standort wird ermittelt</p>
</div>
</div>
`;
}
// ----------------------------------------------------------
// STANDORT AUTOMATISCH ERMITTELN
// ----------------------------------------------------------
async function _tryAutoLocate() {
try {
const pos = await API.getLocation({ timeout: 8000, maximumAge: 300_000 });
await _loadData(pos.lat, pos.lon);
} catch {
_showLocationError();
}
}
function _showLocationError() {
const body = _container.querySelector('#wttr-body');
if (!body) return;
body.innerHTML = `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="font-size:2.5rem;margin-bottom:var(--space-3)">📍</div>
<h3 style="margin-bottom:var(--space-2)">Standort nicht verfügbar</h3>
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-5);max-width:300px;margin-inline:auto">
Bitte erlaube den Zugriff auf deinen Standort, um die Wettervorhersage zu laden.
</p>
<button class="btn btn-primary" id="wttr-btn-retry">
${UI.icon('map-pin')} Nochmal versuchen
</button>
</div>
`;
body.querySelector('#wttr-btn-retry')?.addEventListener('click', () => {
_renderShell();
_tryAutoLocate();
});
}
// ----------------------------------------------------------
// DATEN LADEN
// ----------------------------------------------------------
async function _loadData(lat, lon) {
if (_loading) return;
_loading = true;
try {
_data = await API.weather.forecast(lat, lon);
_selDay = 0;
_renderWeather();
} catch {
const body = _container.querySelector('#wttr-body');
if (body) body.innerHTML = `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="font-size:2.5rem;margin-bottom:var(--space-3)"></div>
<h3 style="margin-bottom:var(--space-2)">Wetter nicht verfügbar</h3>
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-5)">
Die Wetterdaten konnten nicht geladen werden.
</p>
<button class="btn btn-primary" id="wttr-btn-reload">
${UI.icon('arrow-clockwise')} Erneut laden
</button>
</div>
`;
body?.querySelector('#wttr-btn-reload')?.addEventListener('click', () => {
refresh();
});
} finally {
_loading = false;
}
}
// ----------------------------------------------------------
// HAUPT-RENDER
// ----------------------------------------------------------
function _renderWeather() {
const body = _container.querySelector('#wttr-body');
if (!body || !_data) return;
const days = _data.days || [];
if (!days.length) return;
body.innerHTML = `
<!-- 7-Tage-Strip -->
<div id="wttr-strip-wrap"
style="overflow-x:auto;-webkit-overflow-scrolling:touch;
margin-bottom:var(--space-4);
scrollbar-width:none">
<div id="wttr-strip"
style="display:flex;gap:var(--space-2);padding-bottom:4px;min-width:max-content">
${days.map((d, i) => _dayCard(d, i)).join('')}
</div>
</div>
<!-- Detail-Card -->
<div id="wttr-detail" class="section-card"
style="margin-bottom:var(--space-4)">
</div>
<!-- Hunde-Wetter -->
<div id="wttr-dog" class="section-card">
</div>
`;
// Strip-Klick-Events
body.querySelectorAll('[data-wttr-day]').forEach(card => {
card.addEventListener('click', () => {
_selDay = parseInt(card.dataset.wttrDay);
_updateStrip();
_renderDetail();
_renderDog();
});
});
_renderDetail();
_renderDog();
}
// ----------------------------------------------------------
// STRIP AKTUALISIEREN (aktiver Tag)
// ----------------------------------------------------------
function _updateStrip() {
const body = _container.querySelector('#wttr-body');
if (!body) return;
const days = _data?.days || [];
body.querySelectorAll('[data-wttr-day]').forEach((card, i) => {
const active = i === _selDay;
card.style.background = active ? 'var(--c-primary)' : 'var(--c-bg-card)';
card.style.color = active ? '#fff' : 'var(--c-text)';
card.style.borderColor = active ? 'var(--c-primary)' : 'var(--c-border)';
card.style.transform = active ? 'translateY(-2px)' : '';
card.style.boxShadow = active ? '0 4px 12px rgba(196,132,58,0.3)' : '0 1px 3px rgba(0,0,0,0.07)';
// Temperatur-Farbe im aktiven Zustand
const tempEl = card.querySelector('.wttr-temp');
if (tempEl) tempEl.style.color = active ? 'rgba(255,255,255,0.85)' : 'var(--c-text-secondary)';
const precipEl = card.querySelector('.wttr-precip');
if (precipEl) precipEl.style.color = active ? 'rgba(255,255,255,0.75)' : 'var(--c-text-secondary)';
});
}
// ----------------------------------------------------------
// TAG-KARTE (Strip)
// ----------------------------------------------------------
function _dayCard(d, i) {
const active = i === _selDay;
const dateObj = new Date(d.date);
const dayName = i === 0 ? 'Heute' : DAY_NAMES[dateObj.getDay()];
const bg = active ? 'var(--c-primary)' : 'var(--c-bg-card)';
const col = active ? '#fff' : 'var(--c-text)';
const shadow = active
? '0 4px 12px rgba(196,132,58,0.3)'
: '0 1px 3px rgba(0,0,0,0.07)';
const border = active ? 'var(--c-primary)' : 'var(--c-border)';
const transform = active ? 'translateY(-2px)' : '';
const textSec = active ? 'rgba(255,255,255,0.85)' : 'var(--c-text-secondary)';
const textMut = active ? 'rgba(255,255,255,0.75)' : 'var(--c-text-secondary)';
return `
<div data-wttr-day="${i}"
style="display:flex;flex-direction:column;align-items:center;
min-width:72px;padding:var(--space-3) var(--space-2);
border-radius:var(--radius);border:1.5px solid ${border};
background:${bg};color:${col};cursor:pointer;
box-shadow:${shadow};transform:${transform};
transition:all .15s;user-select:none">
<span style="font-size:var(--text-xs);font-weight:600;
margin-bottom:var(--space-1)">${_esc(dayName)}</span>
<div style="margin-bottom:var(--space-1)">${_wmoIcon(d.weathercode, '1.5rem', active ? 'filter:brightness(0) invert(1)' : '')}</div>
<span class="wttr-temp"
style="font-size:var(--text-xs);color:${textSec};white-space:nowrap">
${Math.round(d.temp_max)}°/<span style="opacity:.75">${Math.round(d.temp_min)}°</span>
</span>
<span class="wttr-precip"
style="font-size:10px;color:${textMut};margin-top:2px">
<svg class="ph-icon" style="width:10px;height:10px;vertical-align:-1px;color:#60A5FA"><use href="/icons/phosphor.svg#drop"></use></svg>${d.precip_prob ?? 0}%
</span>
</div>
`;
}
// ----------------------------------------------------------
// DETAIL-CARD
// ----------------------------------------------------------
function _renderDetail() {
const el = _container.querySelector('#wttr-detail');
if (!el || !_data) return;
const d = (_data.days || [])[_selDay];
if (!d) return;
const desc = WMO_DESC[d.weathercode] || '';
const [uvLabel, uvColor] = _uvLabel(d.uv_index ?? 0);
const uvPct = Math.min(100, ((d.uv_index ?? 0) / 11) * 100);
const bft = _beaufort(d.wind_kmh ?? 0);
const windDir = d.wind_dir_deg ?? 0;
const compass = d.wind_dir ?? _compass(windDir);
// Sunrise/Sunset Balken
const now = new Date();
const sunriseStr = d.sunrise || '';
const sunsetStr = d.sunset || '';
let sunPct = 0;
if (sunriseStr && sunsetStr) {
const [rH, rM] = sunriseStr.split(':').map(Number);
const [sH, sM] = sunsetStr.split(':').map(Number);
const riseMin = rH * 60 + rM;
const setMin = sH * 60 + sM;
const curMin = now.getHours() * 60 + now.getMinutes();
sunPct = _selDay === 0
? Math.min(100, Math.max(0, ((curMin - riseMin) / (setMin - riseMin)) * 100))
: 0;
}
el.innerHTML = `
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4)">
${_wmoIcon(d.weathercode, '3.5rem')}
<div>
<div style="font-weight:700;font-size:var(--text-lg)">${_esc(desc)}</div>
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-primary);line-height:1.1">
${Math.round(d.temp_max)}°
<span style="font-size:var(--text-base);font-weight:400;color:var(--c-text-secondary)">
/ ${Math.round(d.temp_min)}°
</span>
</div>
${d.feels_max != null ? `
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
Gefühlt ${Math.round(d.feels_max)}° / ${Math.round(d.feels_min ?? d.feels_max)}°
</div>` : ''}
</div>
</div>
<!-- Sonnenaufgang / -untergang -->
${sunriseStr && sunsetStr ? `
<div style="margin-bottom:var(--space-4)">
<div style="display:flex;justify-content:space-between;
font-size:var(--text-xs);color:var(--c-text-secondary);
margin-bottom:var(--space-1)">
<span style="display:flex;align-items:center;gap:4px">
<svg class="ph-icon" style="width:14px;height:14px;color:#F97316"><use href="/icons/phosphor.svg#sun-horizon"></use></svg>
${_esc(sunriseStr)}
</span>
<span style="display:flex;align-items:center;gap:4px">
${_esc(sunsetStr)}
<svg class="ph-icon" style="width:14px;height:14px;color:#7C3AED"><use href="/icons/phosphor.svg#moon-stars"></use></svg>
</span>
</div>
<div style="height:6px;border-radius:999px;background:var(--c-border);overflow:hidden">
<div style="height:100%;width:${sunPct}%;
background:linear-gradient(90deg,#f97316,#facc15);
border-radius:999px;transition:width .4s"></div>
</div>
</div>` : ''}
<!-- Wind -->
<div style="display:flex;align-items:center;gap:var(--space-3);
padding:var(--space-3);border-radius:var(--radius);
background:var(--c-bg-card);border:1px solid var(--c-border);
margin-bottom:var(--space-3)">
<span style="font-size:1.4rem;transform:rotate(${windDir}deg);display:inline-block;line-height:1">
${UI.icon('arrow-up')}
</span>
<div style="flex:1">
<div style="font-size:var(--text-sm);font-weight:600">
${_esc(compass)} · ${Math.round(d.windspeed_max ?? 0)} km/h
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(bft)}</div>
</div>
${d.precip_sum != null ? `
<div style="text-align:right">
<div style="font-size:var(--text-sm);font-weight:600">
${d.precip_sum} mm
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Niederschlag</div>
</div>` : ''}
</div>
<!-- UV-Index -->
<div>
<div style="display:flex;justify-content:space-between;
font-size:var(--text-xs);margin-bottom:4px">
<span style="color:var(--c-text-secondary)">UV-Index</span>
<span style="font-weight:600;color:${uvColor}">
${d.uv_index ?? 0} ${_esc(uvLabel)}
</span>
</div>
<div style="height:6px;border-radius:999px;background:var(--c-border);overflow:hidden">
<div style="height:100%;width:${uvPct}%;background:${uvColor};
border-radius:999px;transition:width .4s"></div>
</div>
</div>
`;
}
// ----------------------------------------------------------
// HUNDE-WETTER
// ----------------------------------------------------------
function _renderDog() {
const el = _container.querySelector('#wttr-dog');
if (!el || !_data) return;
const d = (_data.days || [])[_selDay];
if (!d) return;
const _POLLEN_NAMES = { erle:'Erle', birke:'Birke', graeser:'Gräser', beifuss:'Beifuß', ambrosia:'Ambrosia' };
let html = `<h3 style="font-size:var(--text-base);font-weight:700;
margin-bottom:var(--space-4)">
<svg class="ph-icon" style="width:1.1em;height:1.1em;vertical-align:-2px;color:var(--c-primary)"><use href="/icons/phosphor.svg#paw-print"></use></svg>
Hunde-Wetter
</h3>`;
// Asphalt-Temperatur
if (d.asphalt_temp != null) {
const [aspText, aspColor, aspAdvice] = _asphaltLevel(d.asphalt_temp);
html += `
<div style="display:flex;align-items:flex-start;gap:var(--space-3);
padding:var(--space-3);border-radius:var(--radius);
background:${aspColor}1a;border:1px solid ${aspColor}55;
margin-bottom:var(--space-3)">
<svg class="ph-icon" style="width:1.3rem;height:1.3rem;flex-shrink:0;color:var(--c-primary)"><use href="/icons/phosphor.svg#paw-print"></use></svg>
<div style="flex:1">
<div style="font-weight:600;font-size:var(--text-sm);color:${aspColor}">
Asphalt ~${Math.round(d.asphalt_temp)}°C ${_esc(aspText)}
</div>
${aspAdvice ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
${_esc(aspAdvice)}
</div>` : ''}
</div>
</div>
`;
}
// Pfoten-Kälteschutz
if (d.paw_cold) {
html += `
<div style="display:flex;align-items:center;gap:var(--space-3);
padding:var(--space-3);border-radius:var(--radius);
background:#3b82f61a;border:1px solid #3b82f655;
margin-bottom:var(--space-3)">
<svg class="ph-icon" style="width:1.3rem;height:1.3rem;color:#38BDF8"><use href="/icons/phosphor.svg#snowflake"></use></svg>
<div style="font-size:var(--text-sm)">
<strong>Kälteschutz für Pfoten:</strong>
Eis und Streusalz können die Pfoten reizen. Pfotenpflege empfohlen.
</div>
</div>
`;
}
// Gewitter
if (d.thunderstorm) {
html += `
<div style="display:flex;align-items:center;gap:var(--space-3);
padding:var(--space-3);border-radius:var(--radius);
background:#f59e0b1a;border:1px solid #f59e0b55;
margin-bottom:var(--space-3)">
<svg class="ph-icon" style="width:1.3rem;height:1.3rem;color:#7C3AED"><use href="/icons/phosphor.svg#cloud-lightning"></use></svg>
<div style="font-size:var(--text-sm)">
<strong>Gewitter erwartet:</strong>
Hunde können auf Gewitter sensibel reagieren. Sichere Umgebung schaffen.
</div>
</div>
`;
}
// Pollenflug
const pollen = d.pollen;
if (pollen && typeof pollen === 'object' && Object.keys(pollen).length) {
const pollenEntries = Object.entries(pollen)
.filter(([, v]) => v != null && v.level > 0);
if (pollenEntries.length) {
html += `
<div style="margin-bottom:var(--space-3)">
<div style="font-size:var(--text-xs);font-weight:600;
color:var(--c-text-secondary);margin-bottom:var(--space-2)">
<svg class="ph-icon" style="width:1em;height:1em;vertical-align:-1px;color:#16A34A"><use href="/icons/phosphor.svg#leaf"></use></svg>
Pollenflug
</div>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)">
${pollenEntries.map(([key, lvlObj]) => {
const col = _pollenColor(lvlObj?.level ?? 0);
const name = _POLLEN_NAMES[key] || key;
const lbl = lvlObj?.label || '';
return `<span style="display:inline-flex;align-items:center;gap:4px;
font-size:var(--text-xs);border-radius:999px;
padding:3px 10px;background:${col}22;
border:1px solid ${col}55;color:${col};font-weight:600">
<span style="width:6px;height:6px;border-radius:50%;background:${col};display:inline-block"></span>
${_esc(name)}: ${_esc(lbl)}
</span>`;
}).join('')}
</div>
</div>
`;
}
}
// Zecken
if (d.zecken != null) {
const [tickLabel, tickColor] = _tickLevel(d.zecken);
html += `
<div style="display:flex;align-items:center;gap:var(--space-3);
padding:var(--space-3);border-radius:var(--radius);
background:${tickColor}1a;border:1px solid ${tickColor}55;
margin-bottom:var(--space-3)">
<svg class="ph-icon" style="width:1.3rem;height:1.3rem;color:#92400E"><use href="/icons/phosphor.svg#bug"></use></svg>
<div style="flex:1">
<span style="font-size:var(--text-sm);font-weight:600">Zecken-Risiko: </span>
<span style="font-size:var(--text-sm);color:${tickColor};font-weight:700">
${_esc(tickLabel)}
</span>
</div>
</div>
`;
}
// Wenn keine Hunde-Daten vorhanden
if (!d.asphalt_temp && !d.paw_cold && !d.thunderstorm
&& !d.zecken && !(pollen && Object.keys(pollen).length)) {
html += `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary)">
Keine besonderen Hinweise für heute.
</p>
`;
}
el.innerHTML = html;
}
// ----------------------------------------------------------
// HILFSFUNKTIONEN — Wetter
// ----------------------------------------------------------
function _beaufort(kmh) {
if (kmh < 2) return 'Windstille';
if (kmh < 12) return 'leicht';
if (kmh < 29) return 'mäßig';
if (kmh < 50) return 'frisch';
if (kmh < 62) return 'stark';
if (kmh < 75) return 'stürmisch';
return 'Sturm';
}
function _uvLabel(uv) {
if (uv <= 2) return ['niedrig', '#4CAF50'];
if (uv <= 5) return ['mittel', '#FFC107'];
if (uv <= 7) return ['hoch', '#FF9800'];
if (uv <= 10) return ['sehr hoch', '#F44336'];
return ['extrem', '#9C27B0'];
}
function _compass(deg) {
const dirs = ['N','NO','O','SO','S','SW','W','NW'];
return dirs[Math.round(deg / 45) % 8];
}
function _asphaltLevel(temp) {
if (temp < 40) return ['Pfoten sicher', '#4CAF50', ''];
if (temp < 50) return ['leicht erwärmt', '#FFC107',
'Kurze Kontaktzeiten sind unbedenklich.'];
if (temp < 60) return ['Vorsicht — Pfoten schützen!', '#FF9800',
'Heiße Oberfläche! Auf Gras ausweichen oder Hundeschuhe verwenden.'];
return ['GEFAHR — Verbrennungsgefahr!', '#F44336',
'Asphalt kann Pfoten in Sekunden verbrennen. Spaziergang vermeiden!'];
}
function _pollenColor(level) {
if (level === 0) return '#9E9E9E';
if (level === 1) return '#4CAF50';
if (level === 2) return '#FFC107';
if (level === 3) return '#FF9800';
return '#F44336'; // level 4+
}
function _tickLevel(risk) {
const r = (risk || '').toLowerCase();
if (r === 'niedrig') return ['niedrig', '#4CAF50'];
if (r === 'mittel') return ['mittel', '#FF9800'];
return ['hoch', '#F44336'];
}
function _esc(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// PUBLIC API
// ----------------------------------------------------------
return { init, refresh };
})();

View file

@ -161,3 +161,251 @@ async def get_weather_summary() -> dict:
logger.info(f"Wetter-Zusammenfassung: max_temp={max_temp}°C, thunderstorm={thunderstorm}")
return {"max_temp_c": max_temp, "thunderstorm": thunderstorm}
# ---------------------------------------------------------------------------
# 7-Tage-Vorhersage
# ---------------------------------------------------------------------------
import asyncio # noqa: E402 — appended section
_forecast_cache: dict = {}
_FORECAST_TTL = 3600 # 1 Stunde
_WDAY_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
def _wind_dir(deg: float) -> str:
dirs = ['N', 'NO', 'O', 'SO', 'S', 'SW', 'W', 'NW']
idx = round(deg / 45) % 8
return dirs[idx]
def _asphalt_temp(air_max: float, uv_max: float) -> tuple[float, str]:
bonus = min(uv_max * 3.0, 30.0)
asphalt = air_max + bonus
if asphalt <= 30:
warn = 'safe'
elif asphalt <= 40:
warn = 'warm'
elif asphalt <= 55:
warn = 'hot'
else:
warn = 'danger'
return round(asphalt, 1), warn
def _pollen_lvl(val: float | None) -> dict:
if val is None:
return {'level': 0, 'label': 'keine'}
if val < 5:
return {'level': 1, 'label': 'niedrig'}
if val < 25:
return {'level': 2, 'label': 'mittel'}
if val < 100:
return {'level': 3, 'label': 'hoch'}
return {'level': 4, 'label': 'sehr hoch'}
async def get_forecast(lat: float, lon: float) -> dict:
"""7-Tage-Wettervorhersage inkl. Pollen, Asphalttemperatur, Zecken. 1h TTL-Cache."""
key = (round(lat, 2), round(lon, 2))
now = time.time()
if key in _forecast_cache:
ts, cached = _forecast_cache[key]
if now - ts < _FORECAST_TTL:
return cached
forecast_url = (
"https://api.open-meteo.com/v1/forecast"
f"?latitude={lat}&longitude={lon}"
"&daily=temperature_2m_max,temperature_2m_min,apparent_temperature_max,"
"apparent_temperature_min,precipitation_probability_max,precipitation_sum,"
"weathercode,windspeed_10m_max,winddirection_10m_dominant,uv_index_max,"
"sunrise,sunset"
"&timezone=auto&forecast_days=7"
)
pollen_url = (
"https://air-quality-api.open-meteo.com/v1/air-quality"
f"?latitude={lat}&longitude={lon}"
"&hourly=alder_pollen,birch_pollen,grass_pollen,mugwort_pollen,ragweed_pollen"
"&timezone=auto&forecast_days=7"
)
async with httpx.AsyncClient(timeout=10.0) as client:
forecast_task = client.get(forecast_url)
pollen_task = client.get(pollen_url)
forecast_resp, pollen_resp = await asyncio.gather(
forecast_task, pollen_task, return_exceptions=True
)
# --- Forecast (required) ---
if isinstance(forecast_resp, Exception):
raise forecast_resp
forecast_resp.raise_for_status()
raw = forecast_resp.json()
daily = raw.get('daily', {})
timezone = raw.get('timezone', 'auto')
dates = daily.get('time', [])
temp_max = daily.get('temperature_2m_max', [])
temp_min = daily.get('temperature_2m_min', [])
feels_max = daily.get('apparent_temperature_max', [])
feels_min = daily.get('apparent_temperature_min', [])
precip_prob = daily.get('precipitation_probability_max', [])
precip_sum = daily.get('precipitation_sum', [])
wcodes = daily.get('weathercode', [])
wind_kmh = daily.get('windspeed_10m_max', [])
wind_deg = daily.get('winddirection_10m_dominant', [])
uv_index = daily.get('uv_index_max', [])
sunrises = daily.get('sunrise', [])
sunsets = daily.get('sunset', [])
# --- Pollen (optional) ---
pollen_daily: dict | None = None
if not isinstance(pollen_resp, Exception):
try:
pollen_resp.raise_for_status()
praw = pollen_resp.json()
hourly = praw.get('hourly', {})
htimes = hourly.get('time', [])
# aggregate hourly → daily max per type
pollen_types = {
'erle': hourly.get('alder_pollen', []),
'birke': hourly.get('birch_pollen', []),
'graeser': hourly.get('grass_pollen', []),
'beifuss': hourly.get('mugwort_pollen', []),
'ambrosia': hourly.get('ragweed_pollen', []),
}
# build date → max mapping per type
pollen_daily = {ptype: {} for ptype in pollen_types}
for i, ts_str in enumerate(htimes):
day_str = ts_str[:10] # 'YYYY-MM-DD'
for ptype, vals in pollen_types.items():
v = vals[i] if i < len(vals) else None
if v is not None:
prev = pollen_daily[ptype].get(day_str)
pollen_daily[ptype][day_str] = max(prev, v) if prev is not None else v
except Exception as e:
logger.warning(f"Pollen-Abruf fehlgeschlagen: {e}")
pollen_daily = None
# --- Assemble days ---
days = []
for i, date_str in enumerate(dates):
wcode = int(wcodes[i]) if i < len(wcodes) and wcodes[i] is not None else 0
desc, icon = _WMO.get(wcode, ('Unbekannt', 'cloud'))
t_max = temp_max[i] if i < len(temp_max) else None
t_min = temp_min[i] if i < len(temp_min) else None
f_max = feels_max[i] if i < len(feels_max) else None
f_min = feels_min[i] if i < len(feels_min) else None
pp = precip_prob[i] if i < len(precip_prob) else None
ps = precip_sum[i] if i < len(precip_sum) else None
wk = wind_kmh[i] if i < len(wind_kmh) else None
wd_deg = wind_deg[i] if i < len(wind_deg) else None
uv = uv_index[i] if i < len(uv_index) else None
# Sunrise / Sunset → HH:MM only (format: "2025-05-02T06:12")
sunrise_raw = sunrises[i] if i < len(sunrises) else None
sunset_raw = sunsets[i] if i < len(sunsets) else None
sunrise_hm = sunrise_raw[11:16] if sunrise_raw and len(sunrise_raw) >= 16 else sunrise_raw
sunset_hm = sunset_raw[11:16] if sunset_raw and len(sunset_raw) >= 16 else sunset_raw
# Weekday
try:
dt_obj = datetime.strptime(date_str, '%Y-%m-%d')
wday = _WDAY_DE[dt_obj.weekday()]
except Exception:
wday = ''
# Asphalt
asphalt_t, asphalt_w = _asphalt_temp(t_max or 0.0, uv or 0.0)
# Zecken
month = datetime.strptime(date_str, '%Y-%m-%d').month
zecken = None
if t_max is not None and t_max > 7.0 and 3 <= month <= 10:
zecken = 'hoch' if t_max > 20 else ('mittel' if t_max > 12 else 'niedrig')
# Pollen
if pollen_daily is not None:
pollen_out = {
pt: _pollen_lvl(pollen_daily[pt].get(date_str))
for pt in ('erle', 'birke', 'graeser', 'beifuss', 'ambrosia')
}
else:
pollen_out = None
days.append({
'date': date_str,
'wday': wday,
'weathercode': wcode,
'desc': desc,
'icon': icon,
'temp_max': t_max,
'temp_min': t_min,
'feels_max': f_max,
'feels_min': f_min,
'precip_prob': pp,
'precip_sum': ps,
'wind_kmh': wk,
'wind_dir': _wind_dir(wd_deg) if wd_deg is not None else None,
'wind_dir_deg': wd_deg,
'uv_index': uv,
'sunrise': sunrise_hm,
'sunset': sunset_hm,
'asphalt_temp': asphalt_t,
'asphalt_warn': asphalt_w,
'pollen': pollen_out,
'zecken': zecken,
'thunderstorm': wcode in {95, 96, 99},
'paw_cold': wcode in {71, 73, 75, 77, 85, 86} or (t_min is not None and t_min < 0),
})
result = {'timezone': timezone, 'days': days}
_forecast_cache[key] = (now, result)
_log_forecast(round(lat, 1), round(lon, 1), days)
return result
def _log_forecast(lat_r: float, lon_r: float, days: list) -> None:
"""Speichert jeden Forecast-Tag in weather_log (INSERT OR IGNORE — kein Überschreiben)."""
if not days:
return
try:
import json
from database import db
with db() as conn:
for d in days:
pollen = d.get('pollen') or {}
conn.execute("""
INSERT OR IGNORE INTO weather_log
(date, lat_r, lon_r,
temp_max, temp_min, feels_max,
precip_prob, precip_sum,
wind_kmh, wind_dir, uv_index,
weathercode, weatherdesc,
sunrise, sunset,
asphalt_temp, asphalt_warn, zecken,
pollen_erle, pollen_birke, pollen_graeser,
pollen_beifuss, pollen_ambrosia,
forecast_json)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", (
d['date'], lat_r, lon_r,
d.get('temp_max'), d.get('temp_min'), d.get('feels_max'),
d.get('precip_prob'), d.get('precip_sum'),
d.get('wind_kmh'), d.get('wind_dir'), d.get('uv_index'),
d.get('weathercode'), d.get('desc'),
d.get('sunrise'), d.get('sunset'),
d.get('asphalt_temp'), d.get('asphalt_warn'), d.get('zecken'),
pollen.get('erle', {}).get('level'),
pollen.get('birke', {}).get('level'),
pollen.get('graeser', {}).get('level'),
pollen.get('beifuss', {}).get('level'),
pollen.get('ambrosia',{}).get('level'),
json.dumps(d, ensure_ascii=False),
))
except Exception as e:
logger.warning(f"weather_log insert fehlgeschlagen: {e}")