Compare commits

...

3 commits

Author SHA1 Message Date
f7370028da KI-Vision-Model, Breed-Scraper, Karte/Routen + Release v1292
Parallele Arbeit (auf Staging mitgetestet): KI-Vision-Model (VISION_MODEL in
ki.py/routes, im KI-Status sichtbar), Breed-Scraper-Anpassungen
(breed_enricher/breed_evaluator, evaluate_enrichment mit user_id),
Karten-/Routen-Änderungen (map.js, routes.js), kleinere UI-Anpassungen
(admin.js, components.css), docker-compose, MARKETING, nav-loop-Test.

Version-Bump auf 1292 (VERSION, sw.js, app.js, index.html, landing.html).
2026-06-14 20:23:21 +02:00
51aad6cf1b Tagebuch-Wochenrückblick + 171 Hundezitate
Wochenrückblick (diary.js _loadPraise) merkt sich jetzt das Wegklicken pro
Kalenderwoche (localStorage by_diary_praise_dismissed) — kommt nicht mehr bei
jedem Öffnen. Lob-Text abwechslungsreich (scheduler.py): wöchentlich
rotierender KI-Fokus + Fallback-Varianten-Pool statt einem festen Satz,
prominente Wochenzahl raus.

WELT-Welt Tageszitat: _QUOTES von 31 auf 171 erweitert (web-recherchiert,
57% mit benannter Quelle statt vorher 29%) — Wiederholung erst nach ~5,7
Monaten statt monatlich.
2026-06-14 20:22:44 +02:00
e86d89f3d9 Notiz-Medien & Sprachnachrichten: Fotos/Videos/Dateien + Audio an Notizen
Wiederverwendbarer UI.noteMediaAttacher für beide Notiz-Stellen (UI.noteModal
+ Notizblock-Seite). note_media-Tabelle + POST/DELETE /api/notes/{id}/media
(vor der gierigen /{parent_type}/{parent_id}-Route). Audio per MediaRecorder,
serverseitig nach m4a/AAC transkodiert (ffmpeg) — iOS spielt Chrome-Opus-webm
nicht ab. UI.lightbox global eingeführt. Mikrofon-Policy microphone=(self) +
CSP media-src 'self' blob:, Datenschutz v6. Disk-Cleanup für note_media bei
Notiz-, Account- und Admin-User-Delete. Reine Medien-Notiz ohne Text erlaubt.
noteModal-Bug gefixt: notes.get() liefert Array -> existing[0] statt
existing?.id (verhinderte Bearbeiten, erzeugte Duplikate). 12 neue Tests.

admin.py enthält außerdem KI-Vision-Statusfelder aus paralleler Arbeit
(nicht sauber trennbar ohne interaktives Staging).
2026-06-14 20:22:35 +02:00
32 changed files with 1449 additions and 165 deletions

View file

@ -18,7 +18,7 @@ _Stand: 2026-06-09_
| Influencer | 🟡 2 Runden (Mai), kaum Resonanz | Runde 3 erst ab ~50 aktiven Usern — jetzt mit Partner-Paket als konkretem Angebot |
| Presse / Blogs | 🟡 1 Runde, kaum Resonanz | keine Massenwelle; Nische zuerst |
| Verzeichnisse / Listings | ⬜ offen | Product Hunt, PWA-Dirs, Google Business EBE |
| SEO / KI-Auffindbarkeit | 🟡 technisch optimiert | Backlinks (Blog-Testberichte) |
| SEO / KI-Auffindbarkeit | 🟡 technisch optimiert | Rechtsseiten crawlbar (v1278) + 3 URLs (datenschutz/agb/impressum) am 09.06. in GSC zur Indexierung eingereicht — in ~Tagen auf „indexiert" prüfen; llms.txt aktuell. Nächster echter Hebel: Backlinks (Blog-Testberichte) |
| Landing Page | 🟡 Redesign-Briefing da | 3 Einstiege, Outcomes statt Features |
| App Store (iOS) | 🟢 **LIVE im App Store** (09.06., Apple-ID 6775012705) | Landing bewirbt „Ban Yaro Go" (Hero + iOS-Abschnitt `#ios-app`) + Profil-Hinweis (Settings → App installieren). Offizielles „Laden im App Store"-Badge nachgebaut als `/img/appstore-badge-de.svg` (brauner Rand #C4843A). **LIVE auf Produktion v1276** (banyaro.app/.de, 09.06.) — Hero-Badge bewusst weggelassen (sonst Eindruck: ganze App im Store) |
| Play Store (Android) | 🔴 ON HOLD | 12 Closed-Tester / 14 Tage fehlen |
@ -41,7 +41,9 @@ Legende: 🟢 läuft/erledigt · 🟡 angefangen · ⬜ offen · 💡 Idee ·
## ✅ Erledigt
- [x] 1000 Flyer A5 (zweiseitig) gedruckt — 03.06.2026
- [x] iOS-App nativ gebaut + **im App Store freigegeben** (Ban Yaro Go, 09.06.) — Details im Repo `banyaro-ios`
- [x] Landing-Promotion für „Ban Yaro Go" gebaut (Hero-Badge + iOS-Abschnitt) — 09.06., develop (URL-Platzhalter offen)
- [x] Landing-Promotion für „Ban Yaro Go" LIVE (iOS-Abschnitt + Profil, eigenes braunes App-Store-Badge; Hero bewusst ohne Badge) — 09.06., Prod v1278
- [x] Datenschutz v4 + AGB v3 (iOS-App-Verarbeitung, kein App-Store-IAP) — 09.06., Prod
- [x] Rechtsseiten crawlbar gemacht (/datenschutz /agb /impressum, einzige Quelle static/*.html) + 3 URLs in GSC zur Indexierung eingereicht — 09.06., Prod v1278
- [x] Influencer-Outreach Runde 1 (5) + Runde 2 (13) — Mai 2026
- [x] SEO-Grundlagen (llms.txt, Landing About-Section)

View file

@ -1 +1 @@
1278
1292

View file

@ -1303,6 +1303,23 @@ def _migrate(conn_factory):
""")
logger.info("Migration: notes Tabelle bereit.")
# Notizen: mehrere Mediendateien pro Notiz (Bild/Video/Audio/Datei)
conn.executescript("""
CREATE TABLE IF NOT EXISTS note_media (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
url TEXT NOT NULL,
media_type TEXT NOT NULL DEFAULT 'image', -- image|video|audio|pdf|file
sort_order INTEGER NOT NULL DEFAULT 0,
img_width INTEGER,
img_height INTEGER,
duration_s INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_note_media_note ON note_media(note_id, sort_order);
""")
logger.info("Migration: note_media Tabelle bereit.")
conn.execute("""
CREATE TABLE IF NOT EXISTS ki_health_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,

View file

@ -25,6 +25,7 @@ KI_MODE = os.getenv("KI_MODE", "local") # off | local | cl
LOCAL_BASE_URL = os.getenv("KI_LOCAL_URL", "http://10.47.11.70:11435/v1")
LOCAL_MODEL = os.getenv("KI_LOCAL_MODEL", "gemma-4-31b-it")
CLOUD_MODEL = os.getenv("KI_CLOUD_MODEL", "claude-sonnet-4-6")
VISION_MODEL = os.getenv("KI_VISION_MODEL", "claude-opus-4-8") # Bild-Analyse (Rassenerkennung)
ANTHROPIC_KEY = os.getenv("ANTHROPIC_API_KEY", "")
CLOUD_WEEKLY_LIMIT = int(os.getenv("KI_CLOUD_WEEKLY_LIMIT", "20"))

View file

@ -106,7 +106,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(self)"
response.headers["Permissions-Policy"] = "camera=(), microphone=(self), geolocation=(self)"
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
@ -114,6 +114,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"worker-src 'self' blob:; " # 'self' = Service Worker (sw.js); blob: = MapLibre-GL-Worker
"style-src 'self' 'unsafe-inline'; " # Inline-Styles bleiben (zu viele Fundstellen für jetzt)
"img-src 'self' data: blob: https:; "
"media-src 'self' blob:; " # Audio/Video-Wiedergabe + lokale blob:-Vorschau (Sprachnotizen)
"connect-src 'self' https:; "
"frame-ancestors 'none'; "
"base-uri 'self'; "

View file

@ -6,6 +6,9 @@ from typing import Tuple
_HEIC_EXTS = {".heic", ".heif"}
_VIDEO_EXTS = {".mov", ".avi", ".m4v"}
# Audio-Endungen, die bereits AAC in einem MP4-Container sind (iOS-Recorder
# liefert audio/mp4) — keine Transkodierung nötig.
_AUDIO_AAC_EXTS = {".m4a", ".aac", ".mp4"}
# Magic-Byte-Signaturen erlaubter Medientypen
_IMAGE_MAGIC = [
@ -51,6 +54,34 @@ def validate_upload(data: bytes, filename: str) -> None:
# HEIC, MOV, AVI, M4V: Pillow/FFmpeg prüfen beim Konvertieren
def validate_audio(data: bytes, content_type: str) -> None:
"""
Prüft Magic Bytes einer Audio-Datei gegen den vom Client gemeldeten content_type.
Wirft ValueError bei Mismatch. WICHTIG: Audio-WebM und Video-WebM teilen sich
dieselbe Magic-Byte-Signatur (Matroska) die Unterscheidung ist NUR über den
content_type möglich, deshalb diese eigene Funktion (validate_upload hat keinen).
"""
if not data:
raise ValueError("Leere Audiodatei.")
ct = (content_type or "").lower().split(";")[0].strip()
if ct in ("audio/mp4", "audio/aac", "audio/x-m4a", "audio/m4a"):
if not (len(data) >= 8 and data[4:8] in (b'ftyp', b'mdat', b'moov', b'free')):
raise ValueError("Datei ist kein gültiges MP4/AAC-Audio.")
elif ct == "audio/webm":
if not data[:4] == b'\x1a\x45\xdf\xa3':
raise ValueError("Datei ist kein gültiges WebM-Audio.")
elif ct in ("audio/ogg", "audio/opus"):
if not data[:4] == b'OggS':
raise ValueError("Datei ist kein gültiges Ogg-Audio.")
elif ct == "audio/mpeg":
if not (data[:3] == b'ID3' or (len(data) >= 2 and data[0] == 0xFF and (data[1] & 0xE0) == 0xE0)):
raise ValueError("Datei ist kein gültiges MP3.")
elif ct in ("audio/wav", "audio/x-wav", "audio/wave"):
if not (data[:4] == b'RIFF' and data[8:12] == b'WAVE'):
raise ValueError("Datei ist kein gültiges WAV.")
# Andere/unbekannte Audio-Typen: ffmpeg prüft beim Transkodieren.
def safe_media_path(media_dir: str, url: str) -> str | None:
"""
Konstruiert einen sicheren Dateipfad aus einer gespeicherten URL.
@ -69,6 +100,21 @@ def safe_media_path(media_dir: str, url: str) -> str | None:
return candidate
def delete_media_files(media_dir: str, urls) -> None:
"""Löscht mehrere Mediendateien samt _preview.webp/_thumb.jpg-Leichen von Disk
(best-effort, Path-Traversal-sicher). Für Cascade-Cleanup bei Lösch-Operationen."""
for url in urls or []:
fp = safe_media_path(media_dir, url)
if not fp:
continue
base = os.path.splitext(fp)[0]
for path in (fp, base + "_preview.webp", base + "_thumb.jpg"):
try:
os.remove(path)
except OSError:
pass
def to_jpeg_if_heic(data: bytes, filename: str) -> Tuple[bytes, str]:
"""Convert HEIC/HEIF to JPEG; return (data, ext) unchanged for all other types."""
ext = os.path.splitext(filename or "")[1].lower()
@ -122,6 +168,44 @@ def to_mp4_if_needed(data: bytes, filename: str) -> Tuple[bytes, str]:
pass
def to_m4a(data: bytes, src_ext: str) -> Tuple[bytes, str]:
"""Transkodiert Audio nach m4a/AAC — universell abspielbar, auch auf iOS.
Nötig, weil Chrome/Firefox Opus-in-WebM/Ogg aufnehmen, das iOS Safari NICHT
abspielen kann. Bereits-AAC-Container (.m4a/.aac/.mp4, u.a. iOS-Recorder)
werden ohne Transkodierung durchgereicht. Bei ffmpeg-Fehler: Original zurück."""
ext = (src_ext or "").lower()
if not ext.startswith("."):
ext = ("." + ext) if ext else ".webm"
if ext in _AUDIO_AAC_EXTS:
return data, ".m4a"
src_path = dst_path = None
try:
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as src:
src.write(data)
src_path = src.name
dst_path = src_path[: -len(ext)] + ".m4a"
result = subprocess.run(
["ffmpeg", "-i", src_path,
"-vn", "-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
"-y", dst_path],
capture_output=True, timeout=120,
)
if result.returncode == 0:
with open(dst_path, "rb") as f:
return f.read(), ".m4a"
return data, ext
except Exception:
return data, ext
finally:
for p in [src_path, dst_path]:
if p:
try:
os.unlink(p)
except OSError:
pass
def extract_gps_from_exif(data: bytes) -> tuple | None:
"""EXIF-GPS aus Bilddaten lesen. Gibt (lat, lon) zurück oder None."""
try:

View file

@ -483,6 +483,16 @@ async def delete_user(uid: int, user=Depends(require_admin)):
conn.execute("DELETE FROM push_subscriptions WHERE user_id=?", (uid,))
conn.execute("DELETE FROM notifications WHERE user_id=?", (uid,))
conn.execute("DELETE FROM forum_posts WHERE user_id=?", (uid,))
# Notiz-Medien: erst Dateien von Disk, dann DB-Zeilen (note_media + notes).
import os as _os
from media_utils import delete_media_files
_nm_urls = [r["url"] for r in conn.execute(
"SELECT nm.url FROM note_media nm JOIN notes n ON n.id = nm.note_id WHERE n.user_id=?",
(uid,)
).fetchall()]
delete_media_files(_os.getenv("MEDIA_DIR", "/data/media"), _nm_urls)
conn.execute("DELETE FROM note_media WHERE note_id IN (SELECT id FROM notes WHERE user_id=?)", (uid,))
conn.execute("DELETE FROM notes WHERE user_id=?", (uid,))
conn.execute("DELETE FROM users WHERE id=?", (uid,))
_audit(conn, user, "user_delete", f"user:{uid} ({target['name']})")
@ -728,7 +738,7 @@ async def ki_history(user=Depends(require_mod)):
@router.get("/ki/status")
async def ki_status(user=Depends(require_mod)):
import httpx
from ki import KI_MODE, LOCAL_BASE_URL, LOCAL_MODEL, CLOUD_MODEL, ANTHROPIC_KEY
from ki import KI_MODE, LOCAL_BASE_URL, LOCAL_MODEL, CLOUD_MODEL, VISION_MODEL, ANTHROPIC_KEY
result = {
"mode": KI_MODE,
@ -737,6 +747,7 @@ async def ki_status(user=Depends(require_mod)):
"local_reachable": False,
"local_model_loaded": None,
"cloud_model": CLOUD_MODEL,
"vision_model": VISION_MODEL,
"cloud_key_set": bool(ANTHROPIC_KEY),
}
@ -944,7 +955,7 @@ async def wiki_enrich(data: WikiEnrichBody, user=Depends(require_mod)):
async def wiki_evaluate(sample: int = 20, user=Depends(require_mod)):
from scraper.breed_evaluator import evaluate_enrichment
sample = max(5, min(sample, 50))
return await evaluate_enrichment(sample_size=sample)
return await evaluate_enrichment(sample_size=sample, user_id=user["id"])
# ------------------------------------------------------------------

View file

@ -298,7 +298,7 @@ Falls kein Hund erkennbar: ist_hund=false und leeres rassen-Array."""
def _sync_call():
client = anthropic.Anthropic(api_key=api_key)
return client.messages.create(
model="claude-opus-4-7",
model=ki_module.VISION_MODEL,
max_tokens=500,
messages=[{
"role": "user",

View file

@ -1,24 +1,33 @@
"""BAN YARO — Notizen Routes"""
import os
import json
import uuid
import asyncio
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
from pydantic import BaseModel, Field
from typing import Optional, Any, List
from database import db
from auth import get_current_user
from timeutils import safe_client_time
from media_utils import (convert_media, extract_video_thumb, safe_media_path,
validate_upload, validate_audio, to_m4a,
generate_preview, get_image_size)
router = APIRouter()
logger = logging.getLogger(__name__)
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class NoteCreate(BaseModel):
text: str = Field(..., min_length=1, max_length=5000)
# Leerer Text erlaubt: eine reine Medien-Notiz (nur Foto/Sprachnachricht)
# wird zuerst leer angelegt, dann werden die Medien angehängt.
text: str = Field("", max_length=5000)
meta_json: Optional[Any] = None
location_name: Optional[str] = Field(None, max_length=300)
parent_label: Optional[str] = Field(None, max_length=200)
@ -35,16 +44,81 @@ class NoteUpdate(BaseModel):
# ------------------------------------------------------------------
# Hilfsfunktionen
# ------------------------------------------------------------------
def _serialize(row) -> dict:
def _serialize(row, media_map: Optional[dict] = None) -> dict:
d = dict(row)
if d.get("meta_json") and isinstance(d["meta_json"], str):
try:
d["meta_json"] = json.loads(d["meta_json"])
except Exception:
pass
if media_map is not None:
d["media_items"] = media_map.get(d["id"], [])
return d
def _fetch_note_media(conn, note_ids: list) -> dict:
"""Lädt alle Medien zu den gegebenen Notiz-IDs als {note_id: [items]}."""
if not note_ids:
return {}
placeholders = ",".join("?" * len(note_ids))
rows = conn.execute(
f"""SELECT id, note_id, url, media_type, sort_order, img_width, img_height, duration_s
FROM note_media WHERE note_id IN ({placeholders})
ORDER BY sort_order, id""",
note_ids
).fetchall()
out: dict = {}
for r in rows:
out.setdefault(r["note_id"], []).append(dict(r))
return out
def _guess_note_media_type(content_type: str, filename: str) -> str:
ct = (content_type or "").lower()
if ct == "application/pdf" or (filename or "").lower().endswith(".pdf"):
return "pdf"
if ct.startswith("audio/"):
return "audio"
if ct.startswith("video/"):
return "video"
if ct.startswith("image/"):
return "image"
ext = os.path.splitext(filename or "")[1].lower()
if ext in {".mp4", ".mov", ".webm", ".m4v", ".avi"}:
return "video"
if ext in {".m4a", ".aac", ".mp3", ".ogg", ".oga", ".wav", ".opus"}:
return "audio"
if ext in {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".heif"}:
return "image"
return "file"
def _delete_note_media_file(url: str) -> None:
"""Löscht eine Mediendatei + zugehörige Preview/Thumb-Leichen von Disk."""
file_path = safe_media_path(MEDIA_DIR, url)
if not file_path:
return
try:
os.remove(file_path)
except OSError:
pass
base = os.path.splitext(file_path)[0]
for leftover in (base + "_preview.webp", base + "_thumb.jpg"):
try:
os.remove(leftover)
except OSError:
pass
def _own_note(note_id: int, user_id: int, conn):
row = conn.execute(
"SELECT id FROM notes WHERE id=? AND user_id=?", (note_id, user_id)
).fetchone()
if not row:
raise HTTPException(404, "Notiz nicht gefunden.")
return row
# ------------------------------------------------------------------
# GET /api/notes — Gesamt-Notizblock mit Filtern
# Alias: GET /api/notes/all/0 (Rückwärtskompatibilität)
@ -97,8 +171,9 @@ async def list_all_notes_filtered(
f"SELECT * FROM notes WHERE {where} ORDER BY {order}",
params
).fetchall()
media_map = _fetch_note_media(conn, [r["id"] for r in rows])
return [_serialize(r) for r in rows]
return [_serialize(r, media_map) for r in rows]
@router.get("/all/0")
@ -109,7 +184,8 @@ async def list_all_notes(user=Depends(get_current_user)):
"SELECT * FROM notes WHERE user_id=? ORDER BY created_at DESC",
(user["id"],)
).fetchall()
return [_serialize(r) for r in rows]
media_map = _fetch_note_media(conn, [r["id"] for r in rows])
return [_serialize(r, media_map) for r in rows]
# ------------------------------------------------------------------
@ -169,6 +245,99 @@ async def ki_analyse(user=Depends(get_current_user)):
return {"suggestions": suggestions, "note_count": note_count}
# ------------------------------------------------------------------
# Medien-Anhänge an Notizen (Bild/Video/Audio/Datei)
# WICHTIG: Diese Routen MÜSSEN vor /{parent_type}/{parent_id} stehen,
# sonst matcht POST /123/media als parent_type=123, parent_id="media"!
# ------------------------------------------------------------------
@router.post("/{note_id}/media")
async def upload_note_media(note_id: int,
file: UploadFile = File(...),
user=Depends(get_current_user)):
with db() as conn:
_own_note(note_id, user["id"], conn)
ct = (file.content_type or "").lower().split(";")[0].strip()
raw_data = await file.read()
loop = asyncio.get_event_loop()
if ct.startswith("audio/"):
media_type = "audio"
try:
validate_audio(raw_data, ct)
except ValueError as e:
raise HTTPException(415, str(e))
src_ext = os.path.splitext(file.filename or "")[1].lower() or ".webm"
raw_data, ext = await loop.run_in_executor(None, lambda: to_m4a(raw_data, src_ext))
else:
# Bild/Video/PDF/sonstige Datei — gleiche Pipeline wie Tagebuch.
try:
validate_upload(raw_data, file.filename or "")
except ValueError as e:
raise HTTPException(415, str(e))
media_type = _guess_note_media_type(ct, file.filename or "")
raw_data, ext = await loop.run_in_executor(
None, lambda: convert_media(raw_data, file.filename or "")
)
if not ext:
ext = ".bin"
filename = f"note_{note_id}_{uuid.uuid4().hex[:8]}{ext}"
path = os.path.join(MEDIA_DIR, "notes", filename)
os.makedirs(os.path.dirname(path), exist_ok=True)
def _write_bytes(p: str, data: bytes) -> None:
with open(p, "wb") as f:
f.write(data)
await loop.run_in_executor(None, lambda: _write_bytes(path, raw_data))
img_size = None
if media_type == "video":
await loop.run_in_executor(None, lambda: extract_video_thumb(path))
elif media_type == "image":
preview_bytes = await loop.run_in_executor(None, lambda: generate_preview(raw_data, ext))
if preview_bytes:
preview_path = os.path.splitext(path)[0] + "_preview.webp"
await loop.run_in_executor(None, lambda: _write_bytes(preview_path, preview_bytes))
img_size = await loop.run_in_executor(None, lambda: get_image_size(raw_data))
media_url = f"/media/notes/{filename}"
with db() as conn:
max_order = conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) FROM note_media WHERE note_id=?",
(note_id,)
).fetchone()[0]
conn.execute(
"""INSERT INTO note_media (note_id, url, media_type, sort_order, img_width, img_height)
VALUES (?,?,?,?,?,?)""",
(note_id, media_url, media_type, max_order + 1,
img_size[0] if img_size else None, img_size[1] if img_size else None)
)
row = conn.execute(
"""SELECT id, note_id, url, media_type, sort_order, img_width, img_height, duration_s
FROM note_media WHERE note_id=? ORDER BY id DESC LIMIT 1""",
(note_id,)
).fetchone()
return dict(row)
@router.delete("/{note_id}/media/{media_id}", status_code=204)
async def delete_note_media(note_id: int, media_id: int,
user=Depends(get_current_user)):
with db() as conn:
_own_note(note_id, user["id"], conn)
row = conn.execute(
"SELECT id, url FROM note_media WHERE id=? AND note_id=?",
(media_id, note_id)
).fetchone()
if not row:
raise HTTPException(404, "Medium nicht gefunden.")
_delete_note_media_file(row["url"])
conn.execute("DELETE FROM note_media WHERE id=?", (media_id,))
return None
# ------------------------------------------------------------------
# GET /api/notes/{parent_type}/{parent_id}
# parent_id kann ein Integer oder ein String-Schlüssel sein.
@ -184,7 +353,8 @@ async def list_notes(parent_type: str, parent_id: str,
ORDER BY created_at DESC""",
(user["id"], parent_type, parent_id)
).fetchall()
return [_serialize(r) for r in rows]
media_map = _fetch_note_media(conn, [r["id"] for r in rows])
return [_serialize(r, media_map) for r in rows]
# ------------------------------------------------------------------
@ -193,9 +363,6 @@ async def list_notes(parent_type: str, parent_id: str,
@router.post("/{parent_type}/{parent_id}", status_code=201)
async def create_note(parent_type: str, parent_id: str, data: NoteCreate,
user=Depends(get_current_user)):
if not data.text.strip():
raise HTTPException(400, "Notiz darf nicht leer sein.")
meta_str = json.dumps(data.meta_json) if data.meta_json is not None else None
now = safe_client_time(data.client_time)
@ -212,7 +379,7 @@ async def create_note(parent_type: str, parent_id: str, data: NoteCreate,
"SELECT * FROM notes WHERE user_id=? AND parent_type=? AND parent_id=? ORDER BY id DESC LIMIT 1",
(user["id"], parent_type, parent_id)
).fetchone()
return _serialize(row)
return _serialize(row, {}) # frisch erstellt → media_items=[]; Upload folgt separat
# ------------------------------------------------------------------
@ -230,8 +397,7 @@ async def update_note(note_id: int, data: NoteUpdate,
updates = {}
if data.text is not None:
if not data.text.strip():
raise HTTPException(400, "Notiz darf nicht leer sein.")
# Leer erlaubt — Medien können die Notiz tragen.
updates["text"] = data.text.strip()
if data.meta_json is not None:
updates["meta_json"] = json.dumps(data.meta_json)
@ -241,14 +407,16 @@ async def update_note(note_id: int, data: NoteUpdate,
updates["parent_label"] = data.parent_label
if not updates:
return _serialize(note)
media_map = _fetch_note_media(conn, [note_id])
return _serialize(note, media_map)
updates["updated_at"] = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
set_clause = ", ".join(f"{k}=?" for k in updates)
values = list(updates.values()) + [note_id]
conn.execute(f"UPDATE notes SET {set_clause} WHERE id=?", values)
row = conn.execute("SELECT * FROM notes WHERE id=?", (note_id,)).fetchone()
return _serialize(row)
media_map = _fetch_note_media(conn, [note_id])
return _serialize(row, media_map)
# ------------------------------------------------------------------
@ -262,5 +430,8 @@ async def delete_note(note_id: int, user=Depends(get_current_user)):
).fetchone()
if not note:
raise HTTPException(404, "Notiz nicht gefunden.")
# Medien-Dateien von Disk räumen (FK-Cascade löscht nur die DB-Zeilen).
for m in conn.execute("SELECT url FROM note_media WHERE note_id=?", (note_id,)).fetchall():
_delete_note_media_file(m["url"])
conn.execute("DELETE FROM notes WHERE id=?", (note_id,))
return None

View file

@ -216,6 +216,16 @@ async def delete_account(user=Depends(get_current_user)):
if col in cols and (tbl, col) not in handled_fk_cols and (tbl, col) not in _ACTOR_COLUMNS:
conn.execute(f"DELETE FROM {tbl} WHERE {col}=?", (uid,))
# note_media-Dateien von Disk räumen — der FK-Cascade beim users-DELETE
# entfernt nur die DB-Zeilen, nicht die Dateien.
import os as _os
from media_utils import delete_media_files
_note_media_urls = [r["url"] for r in conn.execute(
"SELECT nm.url FROM note_media nm JOIN notes n ON n.id = nm.note_id WHERE n.user_id=?",
(uid,)
).fetchall()]
delete_media_files(_os.getenv("MEDIA_DIR", "/data/media"), _note_media_urls)
# Räumt alle verbliebenen ON-DELETE-CASCADE-Tabellen automatisch ab.
conn.execute("DELETE FROM users WHERE id=?", (uid,))
return {"status": "deleted"}

View file

@ -989,6 +989,7 @@ async def _generate_praise_for_dog(dog: dict, user_id: int) -> str:
from datetime import date, timedelta
since = (date.today() - timedelta(days=7)).isoformat()
week_num = date.today().isocalendar()[1]
name = dog["name"]
rasse = dog.get("rasse") or "Hund"
@ -1029,16 +1030,30 @@ async def _generate_praise_for_dog(dog: dict, user_id: int) -> str:
else:
aktivitaet_text = ", ".join(aktivitaet_parts)
# W\u00f6chentlich rotierender Fokus \u2192 die KI klingt nicht jede Woche gleich.
_toene = [
"Betone die enge Verbundenheit zwischen {name} und dir.",
"Hebe die kleinen Abenteuer und besonderen Momente hervor.",
"W\u00fcrdige die sch\u00f6ne gemeinsame Routine und Verl\u00e4sslichkeit.",
"Feiere das gemeinsame Wachsen und die Fortschritte.",
"Betone Ruhe, Geborgenheit und Vertrauen.",
"Schreibe verspielt und mit einem Augenzwinkern.",
]
ton = _toene[week_num % len(_toene)].replace("{name}", name)
prompt = f"""Du bist ein warmer, wohlwollender Begleiter f\u00fcr Hundebesitzer. Schreibe eine kurze pers\u00f6nliche Lob-Nachricht (2-3 S\u00e4tze) f\u00fcr die vergangene Woche.
Hund: {name} ({rasse})
Letzte 7 Tage: {aktivitaet_text}
Dabei seit: {stats.get('weeks_total', 1)} Wochen
Fokus dieser Woche: {ton}
Regeln (unbedingt einhalten):
- Nur loben, NIEMALS Ratschl\u00e4ge geben oder auf Fehlendes hinweisen
- Sprich \u00fcber den Hund: "{name} hatte eine tolle Woche" \u2014 nicht \u00fcber den Besitzer
- Auch bei 0 Aktivit\u00e4ten: positive Formulierung (\u201eAuch ruhige Wochen geh\u00f6ren dazu\u201c)
- Variiere Einstieg und Wortwahl \u2014 klinge NICHT wie letzte Woche
- Erw\u00e4hne KEINE Wochenzahl und keine nackten Statistik-Zahlen
- Maximal 3 kurze S\u00e4tze
- Warm, pers\u00f6nlich, keine Floskeln
- Kein "Du solltest...", kein "Vergiss nicht...", keine Empfehlungen"""
@ -1051,11 +1066,24 @@ Regeln (unbedingt einhalten):
)
return text.strip()
except Exception:
# Fallback wenn KI nicht verfügbar
if aktivitaet_parts:
return f"{name} hatte eine aktive Woche \u2014 {aktivitaet_text}. Das ist toll! \U0001f43e"
else:
return f"Auch ruhige Wochen geh\u00f6ren dazu. {name} wei\u00df, dass du f\u00fcr ihn da bist. \U0001f43e"
# Fallback wenn KI nicht verfügbar — Varianten-Pool, deterministisch pro
# Woche+Hund gewählt, damit der Text nicht jede Woche identisch klingt.
aktiv_varianten = [
f"{name} hatte eine richtig aktive Woche — {aktivitaet_text}. Stark! 🐾",
f"Was für eine Woche, {name}! {aktivitaet_text} — das kann sich sehen lassen. 🌟",
f"{name} war diese Woche voll dabei: {aktivitaet_text}. Weiter so! 🐶",
f"Tolle Woche mit {name}{aktivitaet_text}. Ihr seid ein super Team! 🐾",
f"{aktivitaet_text} — dafür hat sich {name} eine extra Streicheleinheit verdient. ✨",
]
ruhig_varianten = [
f"Auch ruhige Wochen gehören dazu. {name} weiß, dass du für ihn da bist. 🐾",
f"Diese Woche war's gemütlich — und das ist völlig okay. {name} genießt die Zeit mit dir. 🌿",
f"Nicht jede Woche muss voll sein. {name} fühlt sich bei dir einfach wohl. ☀️",
f"Eine entspannte Woche mit {name} — manchmal ist genau das das Schönste. 🐾",
f"{name} und du — auch ohne großes Programm seid ihr ein eingespieltes Team. 🐶",
]
pool = aktiv_varianten if aktivitaet_parts else ruhig_varianten
return pool[(week_num + dog["id"]) % len(pool)]
# ------------------------------------------------------------------

View file

@ -360,30 +360,47 @@ async def _fetch_wikimedia_photo(name: str) -> str | None:
return None
async def _haiku_complete(prompt: str) -> str:
"""Claude Haiku direkt aufrufen (immer Cloud, für maximale Genauigkeit)."""
import anthropic
async def _haiku_complete(prompt: str) -> tuple[str, str]:
"""
Fakten-Extraktion. Bevorzugt Claude Haiku (günstig + genau); ist kein
Cloud-Key gesetzt oder die Cloud nicht erreichbar, fällt es sauber auf das
lokale Modell (LM Studio) zurück, statt hart abzubrechen.
Returns (text, model) model fließt in wiki_rassen.ki_model, damit der
Evaluator lokal-angereicherte Rassen weiterhin zur QC erkennt.
"""
key = os.getenv("ANTHROPIC_API_KEY", "")
if not key:
raise RuntimeError("ANTHROPIC_API_KEY nicht gesetzt")
def _call():
client = anthropic.Anthropic(api_key=key)
return client.messages.create(
model=_HAIKU_MODEL,
max_tokens=700,
system=[{
"type": "text",
"text": _SYSTEM,
"cache_control": {"type": "ephemeral"},
}],
messages=[{"role": "user", "content": prompt}],
)
# 1. Bevorzugt: Claude Haiku direkt (günstigstes Cloud-Modell)
if key:
try:
import anthropic
loop = asyncio.get_event_loop()
resp = await loop.run_in_executor(None, _call)
return resp.content[0].text.strip()
def _call():
client = anthropic.Anthropic(api_key=key)
return client.messages.create(
model=_HAIKU_MODEL,
max_tokens=700,
system=[{
"type": "text",
"text": _SYSTEM,
"cache_control": {"type": "ephemeral"},
}],
messages=[{"role": "user", "content": prompt}],
)
loop = asyncio.get_event_loop()
resp = await loop.run_in_executor(None, _call)
return resp.content[0].text.strip(), _HAIKU_MODEL
except Exception as e:
logger.warning("Haiku (Cloud) nicht erreichbar, Fallback lokal: %s", e)
# 2. Fallback: lokales Modell über die zentrale KI-Abstraktion
import ki
if ki.KI_MODE == "off":
raise RuntimeError("Kein Cloud-Key und KI_MODE=off — Anreicherung nicht möglich.")
text = await ki._local_complete(prompt, _SYSTEM, max_tokens=700, json_mode=False)
return text, ki.LOCAL_MODEL
async def _enrich_one(rasse, dry_run: bool = False) -> bool:
@ -411,12 +428,12 @@ async def _enrich_one(rasse, dry_run: bool = False) -> bool:
logger.info("[DRY-RUN] Gefunden: %s (WP-%s, %d Zeichen)", name, wiki_lang.upper(), len(wiki_text))
return True
# 2. Haiku extrahiert Fakten aus dem Quelltext
# 2. KI extrahiert Fakten aus dem Quelltext (Haiku, sonst lokaler Fallback)
prompt = _PROMPT.format(name=name, lang=wiki_lang.upper(), wiki_text=wiki_text)
try:
raw = await _haiku_complete(prompt)
raw, used_model = await _haiku_complete(prompt)
except Exception as e:
logger.error("Haiku-Anfrage fehlgeschlagen für %s: %s", name, e)
logger.error("KI-Anfrage fehlgeschlagen für %s: %s", name, e)
await asyncio.sleep(3)
return False
@ -435,7 +452,7 @@ async def _enrich_one(rasse, dry_run: bool = False) -> bool:
if "temperament" in updates:
updates["temperament"] = translate_temperament(updates["temperament"])
updates["ki_enriched"] = 1
updates["ki_model"] = _HAIKU_MODEL
updates["ki_model"] = used_model
updates["ki_source"] = f"wikipedia_{wiki_lang}"
cols = ", ".join(f"{k}=?" for k in updates)

View file

@ -43,19 +43,23 @@ Aktivität zur Erfahrung)?
'''
async def evaluate_enrichment(sample_size: int = 20) -> dict:
async def evaluate_enrichment(sample_size: int = 20, user_id: int | None = None) -> dict:
"""
Bewertet `sample_size` zufällig gewählte angereicherte Rassen via Claude.
Bewertet `sample_size` zufällig gewählte angereicherte Rassen als LLM-as-Judge.
Läuft über die zentrale KI-Abstraktion (ki.complete). Admins/Moderatoren werden
dort Cloud-priorisiert (Claude); ist die Cloud nicht erreichbar, fällt die
Bewertung sauber auf das lokale Modell zurück, statt hart abzubrechen.
Returns dict mit aggregierten Scores und Einzelergebnissen.
"""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from database import db
import ki
ANTHROPIC_KEY = os.getenv("ANTHROPIC_API_KEY", "")
if not ANTHROPIC_KEY:
raise RuntimeError("ANTHROPIC_API_KEY nicht gesetzt — Evaluierung benötigt Cloud.")
if ki.KI_MODE == "off":
raise RuntimeError("KI ist deaktiviert (KI_MODE=off) — Evaluierung nicht möglich.")
with db() as conn:
rassen = conn.execute(
@ -65,8 +69,7 @@ async def evaluate_enrichment(sample_size: int = 20) -> dict:
wohnung_geeignet, temperament, ki_model
FROM wiki_rassen
WHERE ki_enriched = 1
AND ki_model IS NOT NULL
AND ki_model NOT LIKE 'claude%'
AND (ki_model IS NULL OR ki_model NOT LIKE 'claude%')
ORDER BY RANDOM()
LIMIT ?""",
(sample_size,),
@ -75,10 +78,10 @@ async def evaluate_enrichment(sample_size: int = 20) -> dict:
if not rassen:
return {"error": "Keine angereicherten Rassen gefunden."}
import anthropic
client = anthropic.Anthropic(api_key=ANTHROPIC_KEY)
_EVAL_SYSTEM = "Du bist ein präziser Qualitätsprüfer. Antworte ausschließlich als JSON."
results = []
sources = set()
totals = {"vollstaendigkeit": 0, "korrektheit": 0,
"sprachqualitaet": 0, "konsistenz": 0, "gesamt": 0}
@ -102,22 +105,17 @@ async def evaluate_enrichment(sample_size: int = 20) -> dict:
data=json.dumps(data, ensure_ascii=False, indent=2),
)
try:
def _call():
return client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=256,
system=[{
"type": "text",
"text": "Du bist ein präziser Qualitätsprüfer. Antworte ausschließlich als JSON.",
"cache_control": {"type": "ephemeral"},
}],
messages=[{"role": "user", "content": prompt}],
)
loop = asyncio.get_event_loop()
resp = await loop.run_in_executor(None, _call)
raw = resp.content[0].text.strip()
raw, source = await ki.complete(
prompt,
system=_EVAL_SYSTEM,
max_tokens=256,
json_mode=True,
user_id=user_id,
return_source=True,
)
sources.add(source)
# JSON extrahieren
# JSON extrahieren (lokale Modelle wrappen gern in ```json … ```)
import re
match = re.search(r"\{[\s\S]+\}", raw)
scores = json.loads(match.group(0)) if match else {}
@ -136,9 +134,12 @@ async def evaluate_enrichment(sample_size: int = 20) -> dict:
count = len([r for r in results if "error" not in r])
averages = {k: round(v / count, 2) for k, v in totals.items()} if count else {}
judge_source = "/".join(sorted(sources)) if sources else "unbekannt"
return {
"sample_size": len(rassen),
"evaluated": count,
"averages": averages,
"judge_source": judge_source, # "cloud" (Claude) oder "local" (LM Studio)
"results": results,
}

View file

@ -3088,12 +3088,23 @@ html.modal-open {
}
.rdr-play svg { width: 14px; height: 14px; }
.rdr-play:active { background: var(--c-border); }
.rdr-slider { flex: 1; min-width: 0; height: 4px; accent-color: var(--c-primary); cursor: pointer; }
.rdr-track-wrap { position: relative; flex: 1; min-width: 0; display: flex; align-items: center; }
.rdr-slider { width: 100%; min-width: 0; height: 4px; accent-color: var(--c-primary); cursor: pointer; }
/* "Jetzt"-Markierung in der Mitte der Zeitleiste (Fangpunkt) */
.rdr-now-tick {
position: absolute; left: 50%; top: 50%;
transform: translate(-50%, -50%);
width: 2px; height: 13px;
background: var(--c-primary); opacity: 0.45;
border-radius: 1px; pointer-events: none; z-index: 1;
}
.rdr-time {
flex-shrink: 0;
font-size: 11px; font-weight: 600;
font-variant-numeric: tabular-nums;
min-width: 74px; text-align: right; color: var(--c-text-secondary);
width: 112px; /* FESTE Breite → Regler bleibt immer gleich lang */
white-space: nowrap; overflow: hidden;
text-align: right; color: var(--c-text-secondary);
}
.rdr-time.is-forecast { color: var(--c-primary); } /* Nowcast/Vorhersage-Frames hervorgehoben */

View file

@ -85,7 +85,7 @@
<li><strong>Accountdaten:</strong> Benutzername, E-Mail-Adresse, Passphrase (verschlüsselt gespeichert)</li>
<li><strong>Hundeprofil:</strong> Name, Rasse, Alter, Foto (freiwillig)</li>
<li><strong>Gesundheitsdaten deines Hundes:</strong> Gewicht, Impfungen, Tierarztbesuche, Medikamente (freiwillig, nur für dich sichtbar)</li>
<li><strong>Tagebuch &amp; Notizen:</strong> Texte, Fotos, Stimmungseinträge (privat, nur für dich)</li>
<li><strong>Tagebuch &amp; Notizen:</strong> Texte, Fotos, Videos, Sprachnachrichten, Stimmungseinträge (privat, nur für dich)</li>
<li><strong>Standortdaten:</strong> Nur nach expliziter Browser-Freigabe — für Karte, Gassi-Treffen,
Giftköder-Meldungen, Nearby-Alerts und Routenaufzeichnung. Standortdaten werden nicht dauerhaft
gespeichert, außer du speicherst selbst eine Route oder Meldung.</li>
@ -94,6 +94,9 @@
<li><strong>Fotos &amp; EXIF-Daten:</strong> Beim Hochladen von Bildern können GPS-Koordinaten
in den EXIF-Metadaten enthalten sein. Diese werden serverseitig ausgelesen, um Fotos auf der
Karte zu verorten — sofern vorhanden. Die Rohdaten werden nicht separat gespeichert.</li>
<li><strong>Mikrofon (Sprachnachrichten):</strong> Nur nach expliziter Browser-Freigabe und nur,
während du in einer Notiz aktiv eine Sprachnachricht aufnimmst. Die Aufnahme wird ausschließlich
auf unseren eigenen Servern gespeichert (kein Drittanbieter), ist privat und nur für dich sichtbar.</li>
<li><strong>Inhalte:</strong> Forenbeiträge, Chatnachrichten, öffentliche Gassi-Treffen</li>
<li><strong>Technische Daten:</strong> IP-Adresse (für Sicherheit und Rate-Limiting, max. 30 Tage),
Browser-Typ</li>
@ -508,7 +511,7 @@
</section>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0">
Stand: Juni 2026 · Version 5
Stand: Juni 2026 · Version 6
</p>
</div>

View file

@ -86,14 +86,14 @@
<title>Ban Yaro</title>
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
<script src="/js/boot-early.js?v=1278"></script>
<script src="/js/boot-early.js?v=1292"></script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=1278">
<link rel="stylesheet" href="/css/layout.css?v=1278">
<link rel="stylesheet" href="/css/components.css?v=1278">
<link rel="stylesheet" href="/css/utilities.css?v=1278">
<link rel="stylesheet" href="/css/lists.css?v=1278">
<link rel="stylesheet" href="/css/design-system.css?v=1292">
<link rel="stylesheet" href="/css/layout.css?v=1292">
<link rel="stylesheet" href="/css/components.css?v=1292">
<link rel="stylesheet" href="/css/utilities.css?v=1292">
<link rel="stylesheet" href="/css/lists.css?v=1292">
</head>
<body>
@ -620,12 +620,12 @@
<div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=1278"></script>
<script src="/js/ui.js?v=1278"></script>
<script src="/js/app.js?v=1278"></script>
<script src="/js/worlds.js?v=1278"></script>
<script src="/js/offline-indicator.js?v=1278"></script>
<script src="/js/contact-form.js?v=1278"></script>
<script src="/js/api.js?v=1292"></script>
<script src="/js/ui.js?v=1292"></script>
<script src="/js/app.js?v=1292"></script>
<script src="/js/worlds.js?v=1292"></script>
<script src="/js/offline-indicator.js?v=1292"></script>
<script src="/js/contact-form.js?v=1292"></script>
<!-- Feature-Seiten werden lazy geladen -->
@ -635,7 +635,7 @@
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
<script src="/js/boot.js?v=1278"></script>
<script src="/js/boot.js?v=1292"></script>
</body>

View file

@ -675,6 +675,12 @@ const API = (() => {
delete(id) {
return del(`/notes/${id}`);
},
uploadMedia(noteId, formData) {
return upload(`/notes/${noteId}/media`, formData);
},
deleteMedia(noteId, mediaId) {
return del(`/notes/${noteId}/media/${mediaId}`);
},
};
// ----------------------------------------------------------

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '1278'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '1292'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator)
window.APP_VERSION = APP_VERSION;

View file

@ -1419,7 +1419,10 @@ window.Page_admin = (() => {
<tbody>${rows}</tbody>
</table>
</div>`;
res.textContent = `✓ Bewertung abgeschlossen`;
const judge = d.judge_source === 'cloud' ? 'Claude (Cloud)'
: d.judge_source === 'local' ? 'lokales Modell ⚠︎'
: (d.judge_source || '');
res.textContent = `✓ Bewertung abgeschlossen — Prüfer: ${judge}`;
} catch (err) {
res.textContent = '✗ Fehler: ' + (err.message || err);
} finally {

View file

@ -283,6 +283,11 @@ window.Page_diary = (() => {
if (!data?.praise) return;
// Weggeklickten Wochenrückblick nicht erneut zeigen (pro Kalenderwoche).
// Nächste Woche (neuer week_key) erscheint er wieder.
const praiseKey = data.week_key || '';
if (praiseKey && localStorage.getItem('by_diary_praise_dismissed') === praiseKey) return;
const card = document.createElement('div');
card.id = 'diary-praise-card';
card.style.cssText = `
@ -316,6 +321,7 @@ window.Page_diary = (() => {
if (list && list.parentNode) list.parentNode.insertBefore(card, list);
card.querySelector('#diary-praise-close')?.addEventListener('click', () => {
if (praiseKey) { try { localStorage.setItem('by_diary_praise_dismissed', praiseKey); } catch (_) {} }
card.style.opacity = '0';
card.style.transition = 'opacity .2s';
setTimeout(() => card.remove(), 200);

View file

@ -451,6 +451,9 @@ window.Page_map = (() => {
let _radarNowIdx = 0; // Index des "jetzt"-Frames (letzte Vergangenheit)
let _radarPlaying = false;
let _radarPlayTimer = null;
let _radarLayerKind = null; // 'rv' (RainViewer-PNG) | 'dwd' (pmtiles) — für sauberen Layer-Wechsel
let _rdrPendingIdx = null; // Regler-Entprellung: zuletzt gewünschter Frame
let _rdrRaf = null; // requestAnimationFrame-Handle für die Koaleszenz
async function _toggleRadar() {
if (!App.hasPro(_appState?.user)) {
@ -461,7 +464,9 @@ window.Page_map = (() => {
if (_radarActive) {
_radarActive = false;
_radarPause();
if (_radarLayer) { _wxRemoveRaster(_radarLayer); _radarLayer = null; }
if (_radarLayer) { _wxRemoveRaster(_radarLayer); _radarLayer = null; _radarLayerKind = null; }
if (_rdrRaf != null) { cancelAnimationFrame(_rdrRaf); _rdrRaf = null; }
_rdrPendingIdx = null;
clearInterval(_radarTimer);
document.getElementById('map-radar-timeline')?.remove();
btn?.classList.remove('active');
@ -602,67 +607,107 @@ window.Page_map = (() => {
async function _loadRadar() {
if (!_radarActive || !_map) return;
try {
const resp = await fetch('https://api.rainviewer.com/public/weather-maps.json', { cache: 'no-store' });
// Cache-Buster: sonst liefert der Service-Worker u. U. einen alten RainViewer-Stand
// (Frames hingen ~50 min nach → DWD-Frische-Check fiel durch, Gerätetest 2026-06-09).
const resp = await fetch(`https://api.rainviewer.com/public/weather-maps.json?_t=${Date.now()}`, { cache: 'no-store' });
const data = await resp.json();
const past = data.radar?.past || [], nowcast = data.radar?.nowcast || [];
if (!past.length && !nowcast.length) return;
_radarHost = data.host || _radarHost;
const rvUrl = f => `${_radarHost}${f.path}/256/{z}/{x}/{y}/4/1_1.png`;
// Default: RainViewer komplett (~2h Vergangenheit + ~30 min Nowcast)
let frames = [...past, ...nowcast].map(f => ({ url: rvUrl(f), time: f.time }));
let nowIdx = Math.max(0, past.length - 1); // "jetzt" = letzter Vergangenheits-Frame
// Symmetrische ±2h-Zeitleiste: letzte 2 h (RainViewer) | jetzt | nächste 2 h (DWD/Nowcast)
const WINDOW = 2 * 60 * 60; // 2 h je Seite
const nowSec = Math.floor(Date.now() / 1000); // Echtzeit-Referenz (Geräteuhr ist zuverlässig)
// DWD-Vorhersage (0120 min, 5-Min-Schritte): ersetzt den RainViewer-Nowcast,
// Vergangenheit bleibt RainViewer (docs/DWD_RAIN_FORECAST_PLAN.md).
// Vergangenheit: RainViewer der letzten 2 h
let pastFrames = past
.filter(f => f.time >= nowSec - WINDOW && f.time <= nowSec)
.map(f => ({ url: rvUrl(f), time: f.time }));
// "Jetzt" + Zukunft — Default: RainViewer-Nowcast (~30 min)
let nowFrame = null;
let futureFrames = nowcast
.filter(f => f.time > nowSec && f.time <= nowSec + WINDOW)
.map(f => ({ url: rvUrl(f), time: f.time }));
// DWD-Vorhersage (0120 min, 5-Min-Schritte) bevorzugt (docs/DWD_RAIN_FORECAST_PLAN.md)
if (_dwdEnabled() && _engineGL && _mapInDwdCoverage()) {
try {
const r = await fetch('/radar/manifest.json', { cache: 'no-store' });
if (r.ok) {
const man = await r.json();
const runT = Math.floor(Date.parse(man.run_time_utc) / 1000);
// Nur wenn der Lauf frisch ist (< 30 min) — sonst RainViewer-Fallback
if (man.frames?.length && (Date.now() / 1000 - runT) < 1800) {
const pastRv = past.filter(f => f.time <= runT).map(f => ({ url: rvUrl(f), time: f.time }));
// Frische des DWD-Laufs gegen die ECHTZEIT prüfen (< 30 min) — NICHT gegen den
// jüngsten RainViewer-Frame, der deutlich nachhängen kann (sonst fällt DWD raus).
if (man.frames?.length && Math.abs(nowSec - runT) < 1800) {
const dwd = man.frames.map(fr => ({
url: `pmtiles://${location.origin}/radar/${man.path}/${fr.file}/{z}/{x}/{y}`,
time: runT + fr.lead_min * 60,
lead: fr.lead_min,
dwd: true,
}));
frames = [...pastRv, ...dwd];
nowIdx = pastRv.length; // DWD lead 0 = "jetzt"
nowFrame = dwd.find(f => f.lead === 0) || null; // lead 0 = "jetzt"
futureFrames = dwd.filter(f => f.lead > 0 && f.time <= runT + WINDOW);
pastFrames = pastFrames.filter(f => f.time < runT); // Überlappung mit DWD-"jetzt" vermeiden
}
}
} catch (e) { /* offline/kein Manifest → RainViewer-Fallback */ }
}
_radarFrames = frames;
_radarNowIdx = nowIdx;
// Kein DWD-"jetzt"? → jüngsten Vergangenheits-Frame (sonst ältesten Zukunfts-Frame) als "jetzt"
if (!nowFrame) {
if (pastFrames.length) nowFrame = pastFrames.pop();
else if (futureFrames.length) nowFrame = futureFrames.shift();
}
if (!nowFrame) return;
_radarFrames = [...pastFrames, nowFrame, ...futureFrames];
_radarNowIdx = pastFrames.length; // "jetzt" liegt direkt nach der Vergangenheit
if (_radarIdx == null || _radarIdx >= _radarFrames.length) _radarIdx = _radarNowIdx;
_showRadarFrame(_radarIdx);
_buildRadarTimeline();
} catch { /* still */ }
}
function _radarUrl(idx) {
return _radarFrames[idx].url;
}
// Frame anzeigen — wenn möglich smooth via setTiles (kein Flackern), sonst Layer neu.
function _showRadarFrame(idx) {
if (!_radarActive || !_radarFrames[idx]) return;
_radarIdx = idx;
const url = _radarUrl(idx);
const f = _radarFrames[idx];
const url = f.url;
const kind = f.dwd ? 'dwd' : 'rv';
const src = _engineGL && _radarLayer && _map.getSource && _map.getSource('wx-radar');
if (src && src.setTiles) {
// setTiles nur innerhalb DESSELBEN Quell-Typs (png↔png bzw. pmtiles↔pmtiles).
// Beim Wechsel RainViewer↔DWD den Layer komplett neu aufbauen — sonst bleiben die
// alten Kacheln stehen (DWD "neutralisiert" die RainViewer-Wolken nicht).
if (src && src.setTiles && kind === _radarLayerKind) {
src.setTiles([url]);
} else {
if (_radarLayer) _wxRemoveRaster(_radarLayer);
_radarLayer = _wxAddRaster('radar', url, 0.7, 7);
_radarLayerKind = kind;
}
_updateRadarTimelineUI();
}
// Slider-Position (01000) ↔ Frame-Index. "jetzt" liegt fix bei 500 (Mitte):
// Vergangenheit nutzt die linke, Vorhersage die rechte Hälfte — unabhängig von der
// Frame-Anzahl je Seite. So sitzt "jetzt" optisch mittig (Fangpunkt).
const RDR_MID = 500, RDR_SNAP = 28;
function _radarPosToIdx(pos) {
const now = _radarNowIdx, last = _radarFrames.length - 1;
if (pos <= RDR_MID) return now > 0 ? Math.round((pos / RDR_MID) * now) : 0;
const fut = last - now;
return fut > 0 ? now + Math.round(((pos - RDR_MID) / RDR_MID) * fut) : now;
}
function _radarIdxToPos(idx) {
const now = _radarNowIdx, last = _radarFrames.length - 1;
if (idx <= now) return now > 0 ? Math.round((idx / now) * RDR_MID) : RDR_MID;
const fut = last - now;
return fut > 0 ? RDR_MID + Math.round(((idx - now) / fut) * RDR_MID) : RDR_MID;
}
function _buildRadarTimeline() {
if (!_radarFrames.length) return;
let el = document.getElementById('map-radar-timeline');
@ -674,17 +719,27 @@ window.Page_map = (() => {
<button id="rdr-play" class="rdr-play" type="button" aria-label="Abspielen">
<svg class="ph-icon" aria-hidden="true" style="width:18px;height:18px"><use href="/icons/phosphor.svg#play"></use></svg>
</button>
<input id="rdr-slider" class="rdr-slider" type="range" min="0" max="${_radarFrames.length - 1}" value="${_radarIdx}" step="1" aria-label="Radar-Zeit">
<div class="rdr-track-wrap">
<span class="rdr-now-tick" aria-hidden="true"></span>
<input id="rdr-slider" class="rdr-slider" type="range" min="0" max="1000" value="${_radarIdxToPos(_radarIdx)}" step="1" aria-label="Radar-Zeit">
</div>
<span id="rdr-time" class="rdr-time"></span>`;
document.getElementById('central-map')?.appendChild(el);
el.querySelector('#rdr-play').addEventListener('click', _toggleRadarPlay);
el.querySelector('#rdr-slider').addEventListener('input', e => {
const idx = parseInt(e.target.value, 10); // ZUERST lesen: _radarPause() setzt slider.value zurück
let pos = parseInt(e.target.value, 10); // ZUERST lesen: _radarPause() setzt slider.value zurück
if (Math.abs(pos - RDR_MID) <= RDR_SNAP) { pos = RDR_MID; e.target.value = RDR_MID; } // Fangpunkt "jetzt"
_radarPause();
_showRadarFrame(idx);
// Entprellen: pro Animationsframe nur EIN setTiles, egal wie schnell gezogen wird
// (sonst bricht jeder neue Frame die laufenden Kachel-Requests ab → AbortError-Spam).
_rdrPendingIdx = _radarPosToIdx(pos);
if (_rdrRaf == null) {
_rdrRaf = requestAnimationFrame(() => {
_rdrRaf = null;
if (_rdrPendingIdx != null) { _showRadarFrame(_rdrPendingIdx); _rdrPendingIdx = null; }
});
}
});
} else {
el.querySelector('#rdr-slider').max = _radarFrames.length - 1;
}
// Breite an die Status-Pill angleichen → gleiche linke + rechte Kante.
const pill = document.querySelector('.map-statusbar');
@ -696,7 +751,7 @@ window.Page_map = (() => {
const slider = document.getElementById('rdr-slider');
const timeEl = document.getElementById('rdr-time');
const playBtn = document.getElementById('rdr-play');
if (slider) slider.value = _radarIdx;
if (slider) slider.value = _radarIdxToPos(_radarIdx);
if (playBtn) playBtn.querySelector('use')?.setAttribute('href', `/icons/phosphor.svg#${_radarPlaying ? 'pause' : 'play'}`);
const f = _radarFrames[_radarIdx];
if (timeEl && f) {
@ -704,8 +759,8 @@ window.Page_map = (() => {
const hhmm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
const diffMin = Math.round((f.time - _radarFrames[_radarNowIdx].time) / 60);
const rel = diffMin === 0 ? 'jetzt' : (diffMin > 0 ? `+${diffMin} Min` : `${diffMin} Min`);
timeEl.textContent = `${hhmm} · ${rel}${f.dwd && diffMin > 0 ? ' · DWD' : ''}`;
timeEl.classList.toggle('is-forecast', diffMin > 0);
timeEl.textContent = `${hhmm} · ${rel}`; // feste Breite (CSS) → Regler springt nicht
timeEl.classList.toggle('is-forecast', diffMin > 0); // Vorhersage-Frames farblich (statt "· DWD"-Text)
}
}
@ -1008,6 +1063,14 @@ window.Page_map = (() => {
center, zoom, attributionControl: false,
maxZoom: 19, dragRotate: false, pitchWithRotate: false,
});
// setTiles bricht beim schnellen Regler-Ziehen laufende Kachel-Requests ab → harmloser
// AbortError. Eigener error-Handler verschluckt ihn, lässt echte Fehler aber durch.
_map.on('error', (e) => {
const err = e && e.error;
const msg = (err && ((err.name || '') + ' ' + (err.message || ''))) || String(e || '');
if (/abort/i.test(msg)) return;
console.warn('MapLibre:', err || e);
});
// Zwei-Finger-Rotation aus → Pinch ist reines Zoom (weniger moveend, klarere Geste).
_map.touchZoomRotate.disableRotation();
_map.touchPitch.disable();

View file

@ -305,6 +305,34 @@ window.Page_notes = (() => {
`;
}
// ----------------------------------------------------------
// Medien-Helfer: Preview-URL ableiten + Indikator-Strip für die Karte
// ----------------------------------------------------------
function _notePreview(url) {
if (!url) return url;
const dot = url.lastIndexOf('.');
if (dot < 0) return url;
if (/\.(mp4|webm|mov|avi|m4v)$/i.test(url)) return url.slice(0, dot) + '_thumb.jpg';
if (/\.(m4a|aac|mp3|ogg|oga|wav|pdf)$/i.test(url)) return url;
return url.slice(0, dot) + '_preview.webp';
}
function _noteMediaStrip(note) {
const items = note.media_items || [];
if (!items.length) return '';
const n = { image: 0, video: 0, audio: 0, pdf: 0, file: 0 };
items.forEach(m => { n[m.media_type] = (n[m.media_type] || 0) + 1; });
const parts = [];
if (n.image) parts.push(['image', n.image]);
if (n.video) parts.push(['video-camera', n.video]);
if (n.audio) parts.push(['microphone', n.audio]);
if (n.pdf + n.file) parts.push(['paperclip', n.pdf + n.file]);
if (!parts.length) return '';
return `<div class="notes-media-strip" style="display:flex;align-items:center;gap:12px;margin-top:6px;font-size:12px;color:var(--c-text-secondary)">
${parts.map(([icon, count]) => `<span style="display:inline-flex;align-items:center;gap:4px">${UI.icon(icon)} ${count}</span>`).join('')}
</div>`;
}
// ----------------------------------------------------------
// Notiz-Karte HTML
// ----------------------------------------------------------
@ -342,7 +370,9 @@ window.Page_notes = (() => {
</div>
<!-- Notiztext -->
<p class="list-item-text notes-card-text">${UI.escape(_truncate(note.text))}</p>
${note.text ? `<p class="list-item-text notes-card-text">${UI.escape(_truncate(note.text))}</p>` : ''}
${_noteMediaStrip(note)}
<!-- Micro-Badges -->
${microBadges.length ? `
@ -495,7 +525,20 @@ window.Page_notes = (() => {
${note.parent_label
? `<div class="text-sm-secondary"><strong>${UI.escape(note.parent_label)}</strong></div>` : ''}
<p class="notes-detail-text">${UI.escape(note.text || '')}</p>
${note.text ? `<p class="notes-detail-text">${UI.escape(note.text)}</p>` : ''}
${(note.media_items && note.media_items.length) ? `
<div style="display:flex;flex-direction:column;gap:8px">
${note.media_items.map(m => {
if (m.media_type === 'image')
return `<img class="notes-detail-media-img" data-full="${m.url}" src="${_notePreview(m.url)}" alt="" style="width:100%;max-height:320px;object-fit:cover;border-radius:var(--radius-md);cursor:pointer">`;
if (m.media_type === 'video')
return `<video src="${m.url}" controls playsinline style="width:100%;max-height:320px;border-radius:var(--radius-md);background:#000"></video>`;
if (m.media_type === 'audio')
return `<audio controls src="${m.url}" style="width:100%"></audio>`;
return `<a href="${m.url}" target="_blank" rel="noopener" class="btn btn-secondary" style="justify-content:flex-start;gap:8px">${UI.icon('file-text')} ${m.media_type === 'pdf' ? 'PDF öffnen' : 'Datei öffnen'}</a>`;
}).join('')}
</div>` : ''}
${microBadges.length ? `
<div class="list-item-micro-badges">
@ -523,6 +566,16 @@ window.Page_notes = (() => {
UI.modal.close();
_openEditModal(note);
});
// Bild-Thumbnails → Lightbox; Preview→Original-Fallback (CSP-konform)
const _imgItems = (note.media_items || []).filter(m => m.media_type === 'image').map(m => ({ url: m.url, type: 'image' }));
document.querySelectorAll('.notes-detail-media-img').forEach(img => {
img.addEventListener('error', () => { if (img.src !== img.dataset.full) img.src = img.dataset.full; }, { once: true });
img.addEventListener('click', () => {
const idx = _imgItems.findIndex(it => it.url === img.dataset.full);
UI.lightbox?.show(_imgItems, Math.max(0, idx));
});
});
}
// ----------------------------------------------------------
@ -587,6 +640,12 @@ window.Page_notes = (() => {
box-sizing:border-box"></textarea>
</div>
<!-- Medien -->
<div class="mb-4">
<label style="display:block;font-size:var(--text-sm);font-weight:600;color:var(--c-text);margin-bottom:var(--space-2)">Medien</label>
<div id="nc-media"></div>
</div>
<div class="flex-gap-3">
<button id="nc-cancel" class="btn btn-ghost flex-1">Abbrechen</button>
<button id="nc-save" class="btn btn-primary flex-1">Speichern</button>
@ -597,40 +656,52 @@ window.Page_notes = (() => {
overlay.innerHTML = _buildContent();
document.body.appendChild(overlay);
const _rebind = () => {
overlay.querySelectorAll('.nc-cat').forEach(btn => {
btn.addEventListener('click', () => {
_selType = btn.dataset.type;
overlay.innerHTML = _buildContent();
_rebind();
overlay.querySelector('#nc-text')?.focus();
const _media = UI.noteMediaAttacher({ containerId: 'nc-media' });
const _remove = () => { _media.destroy(); overlay.remove(); };
// Kategorie-Wechsel: nur Auswahl + Button-Styles aktualisieren — KEIN
// innerHTML-Rebuild, sonst gingen eingegebener Text & angehängte Medien
// (und eine laufende Sprachaufnahme) verloren.
overlay.querySelectorAll('.nc-cat').forEach(btn => {
btn.addEventListener('click', () => {
_selType = btn.dataset.type;
overlay.querySelectorAll('.nc-cat').forEach(b => {
const r = _rubrik(b.dataset.type);
const active = b.dataset.type === _selType;
b.style.borderColor = active ? r.color : 'var(--c-border)';
b.style.background = active ? r.color + '22' : 'var(--c-surface-2)';
b.style.color = active ? r.color : 'var(--c-text-secondary)';
});
});
});
overlay.querySelector('#nc-cancel')?.addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
overlay.querySelector('#nc-cancel')?.addEventListener('click', _remove);
overlay.addEventListener('click', e => { if (e.target === overlay) _remove(); });
overlay.querySelector('#nc-save')?.addEventListener('click', async () => {
const text = overlay.querySelector('#nc-text')?.value?.trim();
if (!text) { UI.toast.warning('Bitte einen Text eingeben.'); return; }
const btn = overlay.querySelector('#nc-save');
await UI.asyncButton(btn, async () => {
const rb = _rubrik(_selType);
await API.notes.create(_selType, 'standalone', {
text,
parent_label: rb.label,
});
overlay.remove();
_filterType = _selType;
await _reload();
UI.toast.success('Notiz gespeichert.');
overlay.querySelector('#nc-save')?.addEventListener('click', async () => {
const text = overlay.querySelector('#nc-text')?.value?.trim();
if (!text && !_media.hasPending()) {
UI.toast.warning('Bitte einen Text eingeben oder ein Medium anhängen.');
return;
}
const btn = overlay.querySelector('#nc-save');
await UI.asyncButton(btn, async () => {
const rb = _rubrik(_selType);
const created = await API.notes.create(_selType, 'standalone', {
text: text || '',
parent_label: rb.label,
});
if (created?.id && _media.hasPending()) {
await _media.uploadAll(created.id, (d, t) => { btn.textContent = `${d}/${t} hochgeladen…`; });
}
_remove();
_filterType = _selType;
await _reload();
UI.toast.success('Notiz gespeichert.');
});
});
setTimeout(() => overlay.querySelector('#nc-text')?.focus(), 100);
};
_rebind();
setTimeout(() => overlay.querySelector('#nc-text')?.focus(), 100);
}
// ----------------------------------------------------------
@ -732,6 +803,13 @@ window.Page_notes = (() => {
</div>
` : ''}
<!-- Medien -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Medien</label>
<div id="notes-edit-media"></div>
</div>
</div>
<!-- Buttons -->
@ -760,6 +838,12 @@ window.Page_notes = (() => {
document.body.appendChild(overlay);
const _media = UI.noteMediaAttacher({
containerId: 'notes-edit-media',
noteId: note.id,
existingMedia: note.media_items || [],
});
let selErfolgsquote = meta.erfolgsquote || null;
let selUmgebung = meta.umgebung || null;
let selStimmung = meta.hund_stimmung || null;
@ -796,14 +880,17 @@ window.Page_notes = (() => {
});
});
function _close() { overlay.remove(); }
function _close() { _media.destroy(); overlay.remove(); }
overlay.addEventListener('click', e => { if (e.target === overlay) _close(); });
overlay.querySelector('#notes-edit-cancel').addEventListener('click', _close);
// Speichern
overlay.querySelector('#notes-edit-save').addEventListener('click', async () => {
const text = overlay.querySelector('#notes-edit-text').value.trim();
if (!text) { UI.toast.warning('Notiz darf nicht leer sein.'); return; }
if (!text && !_media.hasPending() && !(note.media_items || []).length) {
UI.toast.warning('Bitte einen Text eingeben oder ein Medium anhängen.');
return;
}
const saveBtn = overlay.querySelector('#notes-edit-save');
saveBtn.disabled = true;
@ -819,6 +906,10 @@ window.Page_notes = (() => {
text,
meta_json: Object.keys(metaObj).length > 0 ? metaObj : null,
});
if (_media.hasPending()) {
const { uploaded } = await _media.uploadAll(note.id, (d, t) => { saveBtn.textContent = `${d}/${t} hochgeladen…`; });
updated.media_items = (updated.media_items || []).concat(uploaded);
}
const idx = _notes.findIndex(n => n.id === note.id);
if (idx >= 0) _notes[idx] = updated;
_render();

View file

@ -1996,6 +1996,15 @@ window.Page_routes = (() => {
// alles grün, 99 % ab Start (Praxistest René 2026-06-07, Gassirunde Siegenhofen).
// Global nur beim ersten Fix oder wenn verloren (Fenster-Treffer > 300 m entfernt).
let _navIdxInit = false;
// Runde erkennen: Start ≈ Ende (< 60 m). An einem solchen Start/Ende-Knoten ist der
// ENDPUNKT oft ein paar Meter näher als der Startpunkt — die globale Erst-Suche sprang
// dann sofort ans Track-ENDE → 100 % / 0 km ab Sekunde 1, kein Bellen, alles grün, und
// der gelaufene-Weg-Eintrag wurde fälschlich als komplett gespeichert. Der alte 25-m-
// Gleichstand reichte nicht, wenn der Start >28 m weg lag (Siegenhofen René 2026-06-07,
// Deining Angie 2026-06-09).
const _navIsLoop = track.length > 2 &&
_haversineKm(track[0].lat, track[0].lon,
track[track.length - 1].lat, track[track.length - 1].lon) < 0.06;
const _closestIdx = (lat, lon) => {
const search = (from, to) => {
let best = from, bestD = Infinity;
@ -2007,8 +2016,16 @@ window.Page_routes = (() => {
};
if (!_navIdxInit) {
_navIdxInit = true;
// Erster Fix: global, aber bei Quasi-Gleichstand (< 25 m) den START bevorzugen (Loop!)
const g = search(0, track.length - 1);
if (_navIsLoop) {
// Runde: steht man irgendwo in Startnähe (< 150 m), bei 0 % beginnen statt ans
// nahe Track-Ende zu springen. Erst wer weit vom Start steht, ist mitten in die
// Runde eingestiegen → globaler Treffer. Startfenster = erste 15 % (mind. 30 Pkt.).
const win = Math.min(track.length - 1, Math.max(30, Math.floor(track.length * 0.15)));
const s = search(0, win);
return s.bestD < 0.15 ? s.best : g.best;
}
// Punkt-zu-Punkt: bei Quasi-Gleichstand (< 25 m) den START bevorzugen.
const s = search(0, Math.min(track.length - 1, 30));
return (s.bestD - g.bestD) * 1000 < 25 ? s.best : g.best;
}

View file

@ -1706,6 +1706,270 @@ const UI = (() => {
});
}
// ----------------------------------------------------------
// NOTE-MEDIA-ATTACHER — wiederverwendbarer Medien-Anhang für Notizen.
// Buttons (Mediathek/Aufnehmen/Datei/Sprachnachricht), Liste anhängiger
// Dateien, Sprachaufnahme (MediaRecorder) und bereits gespeicherte Medien
// (mit Löschen). Genutzt von noteModal (ui.js) UND der Notizblock-Seite
// (pages/notes.js) — eine Quelle statt Duplikat.
// const m = UI.noteMediaAttacher({ containerId, noteId, existingMedia });
// …im Submit nach create/update: await m.uploadAll(noteId, onProgress);
// ----------------------------------------------------------
function noteMediaAttacher({ containerId, noteId = null, existingMedia = [] } = {}) {
const container = document.getElementById(containerId);
const _noop = { uploadAll: async () => ({ uploaded: [], failed: 0 }), hasPending: () => false, destroy: () => {} };
if (!container) return _noop;
const p = containerId.replace(/[^a-z0-9]/gi, '-');
const ids = {
bar: `${p}-bar`, pending: `${p}-pending`, existing: `${p}-existing`,
rec: `${p}-rec`, recTimer: `${p}-rec-timer`, recStop: `${p}-rec-stop`,
recCancel: `${p}-rec-cancel`, mic: `${p}-mic`,
};
let _noteId = noteId;
const _pending = []; // [{ file, url }]
let _existing = (existingMedia || []).slice();
const RECORDER_OK = !!(window.MediaRecorder && navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
let _stream = null, _rec = null, _chunks = [], _tick = null, _recStart = 0, _recMax = null, _recCancelled = false;
const BTN = 'display:inline-flex;align-items:center;gap:6px;font-size:var(--text-xs);font-weight:600;' +
'padding:7px 12px;border-radius:var(--radius-full);border:1.5px solid var(--c-border);' +
'background:var(--c-surface-2);color:var(--c-text-secondary);cursor:pointer';
const DEL = 'flex-shrink:0;width:26px;height:26px;border-radius:50%;border:none;background:rgba(0,0,0,.08);' +
'color:var(--c-text-secondary);cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;font-size:14px;line-height:1';
const ROW = 'display:flex;align-items:center;gap:10px;padding:6px;border:1px solid var(--c-border);border-radius:var(--radius-md)';
container.innerHTML = `
<div id="${ids.existing}"></div>
<div id="${ids.bar}" style="display:flex;flex-wrap:wrap;gap:8px;margin:6px 0">
<button type="button" id="${p}-gallery" style="${BTN}">${_svgIcon('paperclip')} Foto / Video / Datei</button>
${RECORDER_OK ? `<button type="button" id="${ids.mic}" style="${BTN}">${_svgIcon('microphone')} Sprachnachricht</button>` : ''}
</div>
<div id="${ids.rec}" style="display:none;align-items:center;gap:10px;margin:6px 0;padding:8px 12px;border-radius:var(--radius-md);background:var(--c-danger-subtle,rgba(220,53,53,.12))">
<span style="width:10px;height:10px;border-radius:50%;background:var(--c-danger,#dc3535);flex-shrink:0"></span>
<span id="${ids.recTimer}" style="font-variant-numeric:tabular-nums;font-weight:600;color:var(--c-text)">0:00</span>
<span style="flex:1"></span>
<button type="button" id="${ids.recCancel}" style="${BTN}">Verwerfen</button>
<button type="button" id="${ids.recStop}" style="${BTN};border-color:var(--c-danger,#dc3535);color:var(--c-danger,#dc3535)">${_svgIcon('stop')} Stop</button>
</div>
<div id="${ids.pending}" style="display:flex;flex-direction:column;gap:6px;margin-top:6px"></div>
`;
// ---- Datei-Picker (nativer Browser-Dialog) ----
function _openPicker(opts = {}) {
const tmp = document.createElement('input');
tmp.type = 'file';
tmp.multiple = true;
tmp.accept = 'image/*,video/*';
tmp.style.display = 'none';
if (opts.capture) tmp.setAttribute('capture', opts.capture);
if (opts.noAccept) tmp.removeAttribute('accept');
tmp.addEventListener('change', () => { if (tmp.files.length) _addFiles(tmp.files); tmp.remove(); });
document.body.appendChild(tmp);
tmp.click();
}
function _addFiles(list) {
for (const f of list) _pending.push({ file: f, url: URL.createObjectURL(f) });
_renderPending();
}
function _renderPending() {
const grid = document.getElementById(ids.pending);
if (!grid) return;
grid.innerHTML = _pending.map((it, i) => {
const f = it.file, t = f.type || '';
let preview, label = '';
if (t.startsWith('image/')) {
preview = `<img src="${it.url}" alt="" style="width:48px;height:48px;border-radius:6px;object-fit:cover;flex-shrink:0">`;
label = `<span style="flex:1;min-width:0;font-size:var(--text-xs);color:var(--c-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escape(f.name || 'Bild')}</span>`;
} else if (t.startsWith('video/')) {
preview = `<video src="${it.url}" style="width:48px;height:48px;border-radius:6px;object-fit:cover;flex-shrink:0" muted playsinline></video>`;
label = `<span style="flex:1;min-width:0;font-size:var(--text-xs);color:var(--c-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escape(f.name || 'Video')}</span>`;
} else if (t.startsWith('audio/')) {
preview = `<audio controls src="${it.url}" style="height:36px;flex:1;min-width:0"></audio>`;
} else {
preview = `<svg class="ph-icon" style="width:30px;height:30px;color:var(--c-text-secondary);flex-shrink:0" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg>`;
label = `<span style="flex:1;min-width:0;font-size:var(--text-xs);color:var(--c-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escape(f.name || 'Datei')}</span>`;
}
return `<div style="${ROW}" data-pidx="${i}">${preview}${label}<button type="button" data-pidx="${i}" aria-label="Entfernen" style="${DEL}">✕</button></div>`;
}).join('');
grid.querySelectorAll('button[data-pidx]').forEach(btn => {
btn.addEventListener('click', () => {
const i = parseInt(btn.dataset.pidx, 10);
if (_pending[i]) { try { URL.revokeObjectURL(_pending[i].url); } catch (_) {} _pending.splice(i, 1); }
_renderPending();
});
});
}
function _previewOf(url) {
if (!url) return url;
if (/\.(mp4|webm|mov|avi|m4a|aac|mp3|ogg|oga|wav|pdf)$/i.test(url)) return url;
const dot = url.lastIndexOf('.');
return dot > 0 ? url.slice(0, dot) + '_preview.webp' : url;
}
function _renderExisting() {
const wrap = document.getElementById(ids.existing);
if (!wrap) return;
if (!_existing.length) { wrap.innerHTML = ''; return; }
wrap.innerHTML = `<div style="display:flex;flex-direction:column;gap:6px;margin-bottom:4px">${_existing.map(m => {
let preview, label = '';
if (m.media_type === 'image') {
preview = `<img src="${_previewOf(m.url)}" data-full="${m.url}" alt="" style="width:48px;height:48px;border-radius:6px;object-fit:cover;flex-shrink:0;cursor:pointer" data-full-open="${m.url}">`;
} else if (m.media_type === 'video') {
preview = `<video src="${m.url}" controls playsinline style="height:48px;border-radius:6px;flex:1;min-width:0"></video>`;
} else if (m.media_type === 'audio') {
preview = `<audio controls src="${m.url}" style="height:36px;flex:1;min-width:0"></audio>`;
} else {
preview = `<a href="${m.url}" target="_blank" rel="noopener" style="display:flex;align-items:center;gap:8px;flex:1;min-width:0;color:var(--c-text-secondary);text-decoration:none"><svg class="ph-icon" style="width:28px;height:28px;flex-shrink:0" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg><span style="font-size:var(--text-xs);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escape((m.media_type === 'pdf' ? 'PDF' : 'Datei'))}</span></a>`;
}
return `<div style="${ROW}" data-mid="${m.id}">${preview}${label}<button type="button" data-mid="${m.id}" aria-label="Löschen" style="${DEL}">✕</button></div>`;
}).join('')}</div>`;
// CSP-konformer Preview→Original-Fallback
wrap.querySelectorAll('img[data-full]').forEach(img => {
img.addEventListener('error', () => { if (img.src !== img.dataset.full) img.src = img.dataset.full; }, { once: true });
});
wrap.querySelectorAll('img[data-full-open]').forEach(img => {
img.addEventListener('click', () => {
const imgs = _existing.filter(m => m.media_type === 'image').map(m => ({ url: m.url, type: 'image' }));
const idx = imgs.findIndex(it => it.url === img.dataset.fullOpen);
if (UI.lightbox) UI.lightbox.show(imgs, Math.max(0, idx));
});
});
wrap.querySelectorAll('button[data-mid]').forEach(btn => {
btn.addEventListener('click', async () => {
const mid = parseInt(btn.dataset.mid, 10);
if (_noteId == null) { _existing = _existing.filter(m => m.id !== mid); _renderExisting(); return; }
btn.disabled = true;
try {
await API.notes.deleteMedia(_noteId, mid);
} catch (e) {
if (e?.status !== 404) { btn.disabled = false; toast.error(e.message || 'Löschen fehlgeschlagen.'); return; }
}
_existing = _existing.filter(m => m.id !== mid);
_renderExisting();
toast.success('Medium entfernt.');
});
});
}
// ---- Sprachaufnahme ----
function _stopTracks() { if (_stream) { _stream.getTracks().forEach(t => t.stop()); _stream = null; } }
function _setRecState(on) {
const rec = document.getElementById(ids.rec);
const bar = document.getElementById(ids.bar);
if (rec) rec.style.display = on ? 'flex' : 'none';
if (bar) { bar.style.opacity = on ? '.4' : ''; bar.querySelectorAll('button').forEach(b => b.disabled = on); }
}
function _updateTimer() {
const el = document.getElementById(ids.recTimer);
if (!el) return;
const s = Math.floor((Date.now() - _recStart) / 1000);
el.textContent = `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
async function _startRecording() {
try {
_stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (_) {
toast.error('Kein Mikrofon-Zugriff. Bitte in den Geräte-Einstellungen erlauben.');
return;
}
const cands = ['audio/mp4', 'audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus'];
const mime = cands.find(t => { try { return MediaRecorder.isTypeSupported(t); } catch (_) { return false; } }) || '';
try { _rec = mime ? new MediaRecorder(_stream, { mimeType: mime }) : new MediaRecorder(_stream); }
catch (_) { _rec = new MediaRecorder(_stream); }
_chunks = [];
_recCancelled = false;
_rec.addEventListener('dataavailable', e => { if (e.data && e.data.size) _chunks.push(e.data); });
_rec.addEventListener('stop', () => {
_stopTracks();
if (_tick) { clearInterval(_tick); _tick = null; }
if (_recMax) { clearTimeout(_recMax); _recMax = null; }
_setRecState(false);
if (_recCancelled) { _chunks = []; return; }
const type = _rec.mimeType || mime || 'audio/webm';
const ext = type.includes('mp4') ? '.m4a' : type.includes('ogg') ? '.ogg' : '.webm';
const blob = new Blob(_chunks, { type });
_chunks = [];
if (blob.size > 0) _addFiles([new File([blob], `sprachnachricht${ext}`, { type })]);
});
_rec.start();
_recStart = Date.now();
_setRecState(true);
_updateTimer();
_tick = setInterval(_updateTimer, 250);
_recMax = setTimeout(() => { if (_rec && _rec.state === 'recording') { _recCancelled = false; try { _rec.stop(); } catch (_) {} } }, 5 * 60 * 1000);
}
function _stopRecording(cancel) {
_recCancelled = !!cancel;
if (_rec && _rec.state !== 'inactive') { try { _rec.stop(); } catch (_) {} }
else { _stopTracks(); _setRecState(false); }
}
// ---- Event-Bindung ----
// Ein Button ohne accept: iOS zeigt im Aktionsblatt von sich aus Mediathek,
// Kamera UND Datei-Auswahl — separate Buttons dafür sind überflüssig.
document.getElementById(`${p}-gallery`)?.addEventListener('click', () => _openPicker({ noAccept: true }));
if (RECORDER_OK) document.getElementById(ids.mic)?.addEventListener('click', _startRecording);
document.getElementById(ids.recStop)?.addEventListener('click', () => _stopRecording(false));
document.getElementById(ids.recCancel)?.addEventListener('click', () => _stopRecording(true));
_renderExisting();
_renderPending();
// ---- Öffentliche API ----
async function uploadAll(noteIdArg, onProgress) {
if (noteIdArg != null) _noteId = noteIdArg;
if (!_pending.length) return { uploaded: [], failed: 0 };
const total = _pending.length;
let done = 0;
onProgress?.(0, total);
const results = await Promise.all(_pending.map(async (it) => {
try {
const toUpload = await API.compressImage(it.file); // nur Bilder werden komprimiert
const fd = new FormData();
fd.append('file', toUpload);
const m = await API.notes.uploadMedia(_noteId, fd);
onProgress?.(++done, total);
return { ok: true, m };
} catch (_) {
onProgress?.(++done, total);
return { ok: false };
}
}));
const uploaded = results.filter(r => r.ok).map(r => r.m);
const failed = results.filter(r => !r.ok).length;
if (failed) toast.warning(`${failed} Medi${failed > 1 ? 'en' : 'um'} konnte${failed > 1 ? 'n' : ''} nicht hochgeladen werden.`);
_pending.forEach(it => { try { URL.revokeObjectURL(it.url); } catch (_) {} });
_pending.length = 0;
_renderPending();
_existing = _existing.concat(uploaded);
return { uploaded, failed };
}
function destroy() {
_stopRecording(true);
_stopTracks();
if (_tick) { clearInterval(_tick); _tick = null; }
if (_recMax) { clearTimeout(_recMax); _recMax = null; }
_pending.forEach(it => { try { URL.revokeObjectURL(it.url); } catch (_) {} });
_pending.length = 0;
}
return {
uploadAll,
hasPending: () => _pending.length > 0 || (_rec && _rec.state === 'recording'),
destroy,
};
}
// ----------------------------------------------------------
// NOTE-MODAL — Notiz zu einem beliebigen Objekt (parentType/parentId)
// erstellen/bearbeiten. Zentral, damit nicht jede Seite eine eigene Kopie hat.
@ -1735,6 +1999,7 @@ const UI = (() => {
placeholder="Notiz eingeben…"
style="width:100%;resize:vertical"></textarea>
</form>
<div id="by-note-media" style="margin-top:var(--space-3)"></div>
</div>
<div style="padding:var(--space-3) var(--space-5);border-top:1px solid var(--c-border);
display:flex;gap:var(--space-2);flex-shrink:0">
@ -1752,17 +2017,27 @@ const UI = (() => {
const closeBtn = document.getElementById('by-note-close');
let existingNoteId = null;
let existingNote = null;
try {
const existing = await API.notes.get(parentType, parentId);
if (existing?.id) {
existingNoteId = existing.id;
textarea.value = existing.text || '';
const res = await API.notes.get(parentType, parentId);
// GET /notes/{type}/{id} liefert ein Array (neueste zuerst) — die jüngste
// Notiz bearbeiten statt bei jedem Öffnen eine neue anzulegen (Duplikate).
existingNote = Array.isArray(res) ? res[0] : res;
if (existingNote?.id) {
existingNoteId = existingNote.id;
textarea.value = existingNote.text || '';
}
} catch (_) { /* keine Notiz vorhanden — ok */ }
const _media = noteMediaAttacher({
containerId: 'by-note-media',
noteId: existingNoteId,
existingMedia: existingNote?.media_items || [],
});
setTimeout(() => textarea.focus(), 100);
const _close = () => overlay.remove();
const _close = () => { _media.destroy(); overlay.remove(); };
closeBtn.addEventListener('click', _close);
cancelBtn.addEventListener('click', _close);
overlay.addEventListener('click', e => { if (e.target === overlay) _close(); });
@ -1770,13 +2045,24 @@ const UI = (() => {
document.getElementById('by-note-form').addEventListener('submit', async e => {
e.preventDefault();
const text = textarea.value.trim();
// Reine Medien-Notiz (nur Foto/Sprachnachricht) ist erlaubt — nur ganz
// leer (kein Text, keine Medien) verhindern.
if (!text && !_media.hasPending() && !existingNoteId) {
toast.warning('Bitte einen Text eingeben oder ein Medium anhängen.');
return;
}
setLoading(saveBtn, true);
try {
const payload = { text, parent_label: parentLabel, location_name: locationName || null, client_time: API.clientNow() };
if (existingNoteId) {
await API.notes.update(existingNoteId, payload);
let noteId = existingNoteId;
if (noteId) {
await API.notes.update(noteId, payload);
} else {
await API.notes.create(parentType, parentId, payload);
const created = await API.notes.create(parentType, parentId, payload);
noteId = created?.id;
}
if (noteId && _media.hasPending()) {
await _media.uploadAll(noteId, (d, t) => { saveBtn.textContent = `${d}/${t} hochgeladen…`; });
}
toast.success('Notiz gespeichert.');
_close();
@ -1787,10 +2073,62 @@ const UI = (() => {
});
}
// ----------------------------------------------------------
// LIGHTBOX — Vollbild-Viewer für Bilder/Videos mit Vor/Zurück.
// items: [{ url, type }] (type 'image'|'video', Default 'image') oder
// reine URL-Strings. Global, damit Notizen, Tagebuch & Co. EINEN Viewer
// teilen (app.js erwartet UI.lightbox.show bereits).
// ----------------------------------------------------------
const lightbox = (() => {
function show(items, startIdx = 0) {
const list = (Array.isArray(items) ? items : [items])
.map(it => (typeof it === 'string' ? { url: it } : it))
.filter(it => it && it.url);
if (!list.length) return;
let idx = Math.min(Math.max(0, startIdx | 0), list.length - 1);
const lb = document.createElement('div');
lb.id = 'by-lightbox';
lb.style.cssText = 'position:fixed;inset:0;z-index:3000;background:#000;display:flex;flex-direction:column';
const render = () => {
const m = list[idx];
const media = (m.type === 'video')
? `<video src="${escape(m.url)}" controls autoplay playsinline style="max-width:100%;max-height:100%;display:block"></video>`
: `<img src="${escape(m.url)}" alt="" style="max-width:100%;max-height:100%;object-fit:contain;display:block">`;
lb.innerHTML = `
<div style="flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative">${media}</div>
<div style="display:grid;grid-template-columns:1fr auto 1fr;align-items:center;padding-top:10px;
padding-bottom:calc(env(safe-area-inset-bottom,0px) + 12px);
padding-left:calc(env(safe-area-inset-left,0px) + 16px);
padding-right:calc(env(safe-area-inset-right,0px) + 16px);
flex-shrink:0;background:rgba(0,0,0,.5);gap:8px">
<button id="by-lb-close" style="background:rgba(255,255,255,.15);border:none;border-radius:24px;height:48px;
color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:6px;padding:0 16px;
font-size:15px;font-weight:500">${_svgIcon('arrow-left')} Schließen</button>
<span style="color:rgba(255,255,255,.7);font-size:14px;text-align:center;white-space:nowrap">${list.length > 1 ? `${idx + 1} / ${list.length}` : ''}</span>
${list.length > 1 ? `
<div style="display:flex;gap:8px;justify-content:flex-end">
<button id="by-lb-prev" style="background:rgba(255,255,255,.15);border:none;border-radius:50%;width:48px;height:48px;
color:#fff;font-size:24px;cursor:pointer;display:flex;align-items:center;justify-content:center${idx === 0 ? ';opacity:.3;pointer-events:none' : ''}"></button>
<button id="by-lb-next" style="background:rgba(255,255,255,.15);border:none;border-radius:50%;width:48px;height:48px;
color:#fff;font-size:24px;cursor:pointer;display:flex;align-items:center;justify-content:center${idx === list.length - 1 ? ';opacity:.3;pointer-events:none' : ''}"></button>
</div>` : '<div></div>'}
</div>`;
lb.querySelector('#by-lb-close').addEventListener('click', () => lb.remove());
lb.querySelector('#by-lb-prev')?.addEventListener('click', () => { if (idx > 0) { idx--; render(); } });
lb.querySelector('#by-lb-next')?.addEventListener('click', () => { if (idx < list.length - 1) { idx++; render(); } });
};
render();
document.body.appendChild(lb);
}
return { show };
})();
// Öffentliche API
return {
toast, modal,
noteModal,
noteModal, noteMediaAttacher, lightbox,
setLoading, asyncButton,
formData, setFormError, clearFormErrors,
emptyState, errorState, time, text, money,

View file

@ -1791,6 +1791,146 @@ window.Worlds = (() => {
{ t:'Kein Psychiater der Welt kann so gut zuhören wie ein Hund.', a:'Unbekannt' },
{ t:'Wo Hunde sind, da ist das Zuhause.', a:'Unbekannt' },
{ t:'Der Hund hat keinen Begriff von Vergangenheit oder Zukunft. Er lebt.', a:'Milan Kundera' },
{ t:"Wenn du einen verhungernden Hund aufnimmst und es ihm gutgehen lässt, wird er dich nicht beißen. Das ist der wesentliche Unterschied zwischen Hund und Mensch.", a:"Mark Twain" },
{ t:"Der Hund ist ein Gentleman. Ich hoffe, in seinen Himmel zu kommen, nicht in den der Menschen.", a:"Mark Twain" },
{ t:"Je mehr ich über die Menschen lerne, desto mehr liebe ich meinen Hund.", a:"Mark Twain" },
{ t:"Der Himmel vergibt nach Gunst. Ginge es nach Verdienst, bliebest du draußen und dein Hund käme hinein.", a:"Mark Twain" },
{ t:"Die Treue eines Hundes ist ein kostbares Geschenk, das nicht weniger bindende Pflichten auferlegt als die Freundschaft eines Menschen.", a:"Konrad Lorenz" },
{ t:"Es gibt keine Treue, die nicht schon einmal gebrochen worden wäre, außer der eines wahrhaft treuen Hundes.", a:"Konrad Lorenz" },
{ t:"Die Bindung an einen echten Hund ist so beständig, wie es Bande auf dieser Erde nur sein können.", a:"Konrad Lorenz" },
{ t:"Der Wunsch, einen Hund zu halten, entspringt der uralten Sehnsucht des zivilisierten Menschen nach dem verlorenen Paradies.", a:"Konrad Lorenz" },
{ t:"Wenn ich daran denke, dass mein Hund mich mehr liebt, als ich ihn, dann beschämt mich das.", a:"Konrad Lorenz" },
{ t:"Der Hund ist, mit Recht, das Sinnbild der Treue.", a:"Arthur Schopenhauer" },
{ t:"Woran sollte man sich von der Falschheit der Menschen erholen, wenn die Hunde nicht wären, in deren ehrliches Gesicht man ohne Misstrauen blicken kann.", a:"Arthur Schopenhauer" },
{ t:"Der Anblick jedes Tieres erfreut mich unmittelbar und mir geht dabei das Herz auf, am meisten bei dem der Hunde.", a:"Arthur Schopenhauer" },
{ t:"Hunde lieben ihre Freunde und beißen ihre Feinde, ganz anders als Menschen, die niemals rein lieben, sondern stets Liebe und Hass vermengen.", a:"Sigmund Freud" },
{ t:"Hunde schenken Zuneigung ohne Zwiespalt und die Schönheit eines Daseins, das ganz in sich ruht.", a:"Sigmund Freud" },
{ t:"Gibt es im Himmel keine Hunde, dann will ich, wenn ich sterbe, dorthin gehen, wo sie hingekommen sind.", a:"Will Rogers" },
{ t:"Für seinen Hund ist jeder Mensch ein Napoleon, daher die anhaltende Beliebtheit der Hunde.", a:"Aldous Huxley" },
{ t:"Bevor du einen Hund hast, kannst du dir kaum vorstellen, wie das Leben mit ihm wäre. Danach kannst du dir kein anderes Leben mehr vorstellen.", a:"Caroline Knapp" },
{ t:"Wer einmal einen wundervollen Hund hatte, dessen Leben ist ohne einen ärmer.", a:"Dean Koontz" },
{ t:"Einen Hund zu streicheln und zu kraulen kann den Geist so beruhigen wie tiefe Meditation und ist fast so gut für die Seele wie ein Gebet.", a:"Dean Koontz" },
{ t:"Tausendmal hat er mir gesagt, dass ich sein Grund zu leben bin, durch die Art, wie er sich an mein Bein lehnt.", a:"Gene Hill" },
{ t:"Hunde sind unser Bindeglied zum Paradies. Sie kennen weder Bosheit noch Neid noch Unzufriedenheit.", a:"Milan Kundera" },
{ t:"Mit einem Hund an einem schönen Nachmittag auf einem Hügel zu sitzen ist wie eine Rückkehr nach Eden.", a:"Milan Kundera" },
{ t:"Hunde sprechen sehr wohl, doch nur zu denen, die zu lauschen verstehen.", a:"Orhan Pamuk" },
{ t:"Tiere sind so angenehme Freunde, sie stellen keine Fragen und üben keine Kritik.", a:"George Eliot" },
{ t:"Das größte Vergnügen mit einem Hund ist, dass man sich vor ihm zum Narren machen kann und er nicht nur nicht tadelt, sondern selbst mitmacht.", a:"Samuel Butler" },
{ t:"Bedenke, dass der Allmächtige, der uns den Hund zum Gefährten gab, ihm ein edles Wesen verlieh, das des Betrugs unfähig ist.", a:"Sir Walter Scott" },
{ t:"Ich habe oft über den Grund nachgedacht, warum Hunde so kurz leben, und bin überzeugt, es geschieht aus Mitleid mit dem Menschen.", a:"Sir Walter Scott" },
{ t:"Hunde beißen mich nie. Nur Menschen.", a:"Marilyn Monroe" },
{ t:"Wer hält dich für so großartig wie dein Hund.", a:"Audrey Hepburn" },
{ t:"Ich gehe mit meinen Hunden, das hält mich fit. Ich rede mit meinen Hunden, das hält mich gesund.", a:"Audrey Hepburn" },
{ t:"Sobald er seinen Herrn erblickte, ließ Argos die Ohren sinken und wedelte mit dem Schwanz, doch zu ihm hin zu kommen vermochte er nicht mehr.", a:"Homer" },
{ t:"Hunde und Philosophen tun das meiste Gute und erhalten den geringsten Lohn.", a:"Diogenes" },
{ t:"Hunde sind besser als Menschen, denn sie wissen, doch sie verraten es nicht.", a:"Emily Dickinson" },
{ t:"Bei Schlachten, die das Schicksal von Völkern entschieden, blieb ich ungerührt. Hier aber, beim Kummer eines einzigen Hundes, war ich zu Tränen gerührt.", a:"Napoleon Bonaparte" },
{ t:"Wenn Hunde in den Himmel kommen, brauchen sie keine Flügel, denn Gott weiß, dass Hunde das Laufen am meisten lieben.", a:"Cynthia Rylant" },
{ t:"Meine ganze Bestimmung war es, ihn zu lieben, bei ihm zu sein und ihn glücklich zu machen.", a:"W. Bruce Cameron" },
{ t:"Meine Hundefreunde scheinen meine Grenzen zu verstehen und bleiben immer dicht an meiner Seite.", a:"Helen Keller" },
{ t:"Gib einem Hund dein Herz, das er zerreißen kann.", a:"Rudyard Kipling" },
{ t:"Ein Hund lehrt einen Jungen Treue, Beharrlichkeit und sich dreimal zu drehen, bevor man sich hinlegt.", a:"Robert Benchley" },
{ t:"Du kannst zu einem Hund den größten Unsinn sagen, und er schaut dich an, als wollte er sagen: Donnerwetter, du hast recht, darauf wäre ich nie gekommen.", a:"Dave Barry" },
{ t:"Wenn ich an die Unsterblichkeit glaube, dann daran, dass gewisse Hunde in den Himmel kommen, und nur sehr, sehr wenige Menschen.", a:"James Thurber" },
{ t:"Eine Tür ist das, auf deren falscher Seite ein Hund sich ständig befindet.", a:"Ogden Nash" },
{ t:"Natürlich kann man ohne Hund leben, es lohnt sich nur nicht.", a:"Heinz Rühmann" },
{ t:"Ein Leben ohne Mops ist möglich, aber sinnlos.", a:"Loriot" },
{ t:"Die Welt wäre ein schönerer Ort, wenn jeder so bedingungslos lieben könnte wie ein Hund.", a:"M.K. Clinton" },
{ t:"Der durchschnittliche Hund ist ein netterer Mensch als der durchschnittliche Mensch.", a:"Andy Rooney" },
{ t:"Niemand schätzt das ganz besondere Genie deiner Unterhaltung so sehr wie dein Hund.", a:"Christopher Morley" },
{ t:"Ich habe meinem Schmerz einen Namen gegeben und nenne ihn Hund, denn er ist ebenso treu und klug wie jeder andere Hund.", a:"Friedrich Nietzsche" },
{ t:"Dem Hunde, wenn er gut gezogen, wird selbst ein weiser Mann gewogen.", a:"Johann Wolfgang von Goethe" },
{ t:"Verstößt ein Herr seinen Hund, weil er ihm das Brot nicht mehr verdienen kann, so zeigt das stets eine sehr kleine Seele des Herrn an.", a:"Immanuel Kant" },
{ t:"Wenn du keinen Hund besitzt, ist nicht unbedingt etwas mit dir verkehrt, aber vielleicht stimmt etwas mit deinem Leben nicht.", a:"Roger Caras" },
{ t:"Alte Hunde sind wie alte Schuhe, bequem. Sie sind vielleicht etwas aus der Form, aber sie passen einfach gut.", a:"Bonnie Wilcox" },
{ t:"Kommt ein Hund nicht zu dir, nachdem er dir ins Gesicht gesehen hat, so solltest du heimgehen und dein Gewissen prüfen.", a:"Woodrow Wilson" },
{ t:"Springt ein Hund auf deinen Schoß, dann weil er dich mag. Tut eine Katze dasselbe, ist es nur, weil dein Schoß wärmer ist.", a:"Alfred North Whitehead" },
{ t:"Geld kann dir einen feinen Hund kaufen, aber nur Liebe bringt ihn dazu, mit dem Schwanz zu wedeln.", a:"Kinky Friedman" },
{ t:"Wenn ich für eine Reise den Koffer hervorhole, weiß er es lange vorher und gerät in einen Zustand milder Aufregung.", a:"John Steinbeck" },
{ t:"Ein Hund ist ein Band zwischen Fremden.", a:"John Steinbeck" },
{ t:"Mein kleiner Hund, ein Herzschlag zu meinen Füßen.", a:"Edith Wharton" },
{ t:"Geschaffen wurde der Hund eigens für die Kinder. Er ist der Gott des Übermuts.", a:"Henry Ward Beecher" },
{ t:"Hunde sind klug. Sie kriechen in eine stille Ecke und lecken ihre Wunden und kehren erst in die Welt zurück, wenn sie wieder heil sind.", a:"Agatha Christie" },
{ t:"Schönheit ohne Eitelkeit, Stärke ohne Übermut, Mut ohne Wildheit und alle Tugenden des Menschen ohne seine Laster.", a:"Lord Byron" },
{ t:"Gott dreht Wolken um und um, um den Hunden im Hundehimmel flauschige Betten zu bereiten.", a:"Cynthia Rylant" },
{ t:"Hunde besitzen eine Eigenschaft, die unter Menschen selten ist, nämlich zu erkennen, wer Hilfe braucht, und sie zu geben.", a:"Caroline Knapp" },
{ t:"In manchen Dingen ist mein Hund klüger als ich, in anderen ist er bodenlos unwissend.", a:"John Steinbeck" },
{ t:"Ein Hund zur Hand ist besser als ein Bruder in der Ferne.", a:"Persisches Sprichwort" },
{ t:"Wenn du bei jedem bellenden Hund stehen bleibst, beendest du deine Reise nie.", a:"Arabisches Sprichwort" },
{ t:"Es ist schwer, einen so treuen Gefährten zu finden wie einen Hund.", a:"Mongolisches Sprichwort" },
{ t:"Ein Hund ohne Schwanz kann nicht zeigen, dass er sich freut.", a:"Albanisches Sprichwort" },
{ t:"Ein Hund, der mit dem Schwanz wedelt, bezieht keine Prügel.", a:"Japanisches Sprichwort" },
{ t:"Der hungrige Hund fürchtet den Stock nicht.", a:"Japanisches Sprichwort" },
{ t:"Treffen sich im Paradies eine Menschenseele und eine Hundeseele, verneigt sich der Mensch vor dem Hund.", a:"Sibirisches Sprichwort" },
{ t:"Solange der Mensch denkt, Tiere fühlten nicht, fühlen Tiere, dass der Mensch nicht denkt.", a:"Indianische Weisheit" },
{ t:"Mit Hunden zu leben tut dem Menschen gut.", a:"Tibetisches Sprichwort" },
{ t:"Ein Hund ist ein Herz auf vier Beinen.", a:"Irisches Sprichwort" },
{ t:"Der Hund vergisst den einen Bissen nicht, und wirfst du ihm auch hundert Steine nach.", a:"Chinesisches Sprichwort" },
{ t:"Sei der Freund meines Hundes, dann bist du auch der meine.", a:"Indianische Weisheit" },
{ t:"Hüte dich vor dem Menschen, der nicht spricht, und vor dem Hund, der nicht bellt.", a:"Indianische Weisheit" },
{ t:"Ein kluger Hund bellt nicht ohne Grund.", a:"Französisches Sprichwort" },
{ t:"In seiner eigenen Hütte ist jeder Hund ein Löwe.", a:"Französisches Sprichwort" },
{ t:"Hunde, die sich beißen, halten gegen den Wolf zusammen.", a:"Armenisches Sprichwort" },
{ t:"Der Hund bellt, doch die Karawane zieht weiter.", a:"Türkisches Sprichwort" },
{ t:"Hat ein Armer den Hund großgezogen, folgt er keinem Reichen mehr.", a:"Mongolisches Sprichwort" },
{ t:"Hat der Hund zu viele Herren, schläft er hungrig ein.", a:"Afrikanisches Sprichwort" },
{ t:"Ich hoffe, einmal der Mensch zu werden, für den mein Hund mich hält.", a:"Ungarisches Sprichwort" },
{ t:"Eines Hundes Treue währt ein ganzes Leben lang.", a:"Spanisches Sprichwort" },
{ t:"Faule Schäfer haben die besten Hunde.", a:"Deutsches Sprichwort" },
{ t:"Hunde, die viel bellen, beißen selten.", a:"Italienisches Sprichwort" },
{ t:"Wer einen guten Hund hat, braucht keinen Wächter.", a:"Italienisches Sprichwort" },
{ t:"Wo der Hund frei laufen darf, ist das Glück nicht weit.", a:"Unbekannt" },
{ t:"Ein Hund schaut nicht auf deinen Stand, nur auf dein Herz.", a:"Unbekannt" },
{ t:"Dem Hund ist gleich, ob du reich bist; ihm reicht, dass du heimkommst.", a:"Unbekannt" },
{ t:"Ein Hund braucht keine Worte, um dich zu trösten.", a:"Unbekannt" },
{ t:"Der beste Platz der Welt ist neben einem Hund.", a:"Unbekannt" },
{ t:"Ein Tag mit Hund ist nie ganz verloren.", a:"Unbekannt" },
{ t:"Ein Hund füllt die Stille im Haus mit leisem Glück.", a:"Unbekannt" },
{ t:"Hunde messen die Zeit nicht in Stunden, sondern in Spaziergängen.", a:"Unbekannt" },
{ t:"Ein Hund findet zum Glück immer den kürzesten Weg.", a:"Unbekannt" },
{ t:"Mit einem Hund an der Seite läuft man nie allein.", a:"Unbekannt" },
{ t:"Ein Hund vergibt schneller, als wir uns entschuldigen können.", a:"Unbekannt" },
{ t:"Wer die Sprache der Hunde lernt, hört auf zu reden und beginnt zu fühlen.", a:"Unbekannt" },
{ t:"Ein Hund kennt deinen Namen nicht, aber er kennt dein Herz.", a:"Unbekannt" },
{ t:"Hunde sind die Pünktlichsten, wenn es ums Glücklichsein geht.", a:"Unbekannt" },
{ t:"Ein Hund wartet nicht auf morgen, um dich heute zu lieben.", a:"Unbekannt" },
{ t:"Manche Engel haben Fell und kalte Pfoten.", a:"Unbekannt" },
{ t:"Ein Hund nimmt dich, wie du bist, und macht dich trotzdem besser.", a:"Unbekannt" },
{ t:"Was ein Hund über Freundschaft weiß, lernt der Mensch ein Leben lang.", a:"Unbekannt" },
{ t:"Hunde haben kurze Leben, weil sie das Lieben so gut können, dass sie keine Zeit verschwenden.", a:"Unbekannt" },
{ t:"Glück hat vier Pfoten und einen wedelnden Schwanz.", a:"Unbekannt" },
{ t:"Ein Hund teilt dein Schweigen, ohne es zu füllen.", a:"Unbekannt" },
{ t:"Der treueste Blick der Welt kommt von unten und wedelt dabei.", a:"Unbekannt" },
{ t:"Ein Hund braucht keinen Sonntag, jeder Tag mit dir ist ihm Feiertag.", a:"Unbekannt" },
{ t:"Wo ein Hund die Stiefel bringt, fehlt es nie an Liebe.", a:"Unbekannt" },
{ t:"Hunde rechnen nicht nach, wie viel du gibst; sie geben einfach alles zurück.", a:"Unbekannt" },
{ t:"Ein Hund sieht nicht, wie du aussiehst, sondern wer du bist.", a:"Unbekannt" },
{ t:"Ein Hund macht aus einem Spaziergang ein kleines Abenteuer.", a:"Unbekannt" },
{ t:"Wer einem Hund ins Auge sieht, schaut der Ehrlichkeit beim Atmen zu.", a:"Unbekannt" },
{ t:"Ein Hund spürt deinen schlechten Tag und bleibt trotzdem.", a:"Unbekannt" },
{ t:"Das Schwierigste am Hundeleben ist, dass es zu kurz für so viel Liebe ist.", a:"Unbekannt" },
{ t:"Ein Hund braucht wenig und schenkt davon das Meiste.", a:"Unbekannt" },
{ t:"Hunde sind der Beweis, dass Treue keine Worte braucht.", a:"Unbekannt" },
{ t:"Ein wedelnder Schwanz hat schon manchen Tag gerettet.", a:"Unbekannt" },
{ t:"Wer einen Hund versteht, braucht den Menschen weniger zu erklären.", a:"Unbekannt" },
{ t:"Ein Hund spart seine Liebe nicht auf, er verschenkt sie sofort und vollständig.", a:"Unbekannt" },
{ t:"Die kürzeste Verbindung zwischen zwei Menschen ist manchmal eine Hundeleine.", a:"Unbekannt" },
{ t:"Ein Hund kennt keinen Stolz, nur Wiedersehensfreude.", a:"Unbekannt" },
{ t:"Wer mit einem Hund alt wird, lernt das Glück im Kleinen.", a:"Unbekannt" },
{ t:"Ein Hund hält dir keine Reden, er hält dir die Treue.", a:"Unbekannt" },
{ t:"Vor einem Hund muss man nichts vorgeben; er liebt das Echte.", a:"Unbekannt" },
{ t:"Ein Hund schreibt keine Briefe, doch sein Schwanz erzählt alles.", a:"Unbekannt" },
{ t:"Hunde nehmen die kleinen Dinge ernst, darum sind sie so groß im Lieben.", a:"Unbekannt" },
{ t:"Ein Hund verzeiht dir den Regen, solange du mit ihm hinausgehst.", a:"Unbekannt" },
{ t:"Wer das Vertrauen eines Hundes gewinnt, hat etwas Selteneres als Gold.", a:"Unbekannt" },
{ t:"Ein Hund macht stille Tage warm und laute Tage leiser.", a:"Unbekannt" },
{ t:"Die treueste Uhr im Haus ist der Hund vor dem Futternapf.", a:"Unbekannt" },
{ t:"Ein Hund fragt nicht, wie der Tag war; er macht ihn einfach besser.", a:"Unbekannt" },
{ t:"Wer einen Hund an der Seite hat, ist nirgends ganz fremd.", a:"Unbekannt" },
{ t:"Ein Hund kennt nur ein Tempo bei der Liebe: sofort.", a:"Unbekannt" },
{ t:"Ein Hund am Feuer wärmt mehr als das Feuer selbst.", a:"Unbekannt" },
{ t:"Wer den Hund gut behandelt, dem öffnet sich das Herz von selbst.", a:"Unbekannt" },
{ t:"Ein Hund braucht keinen Kalender, er weiß genau, wann du nach Hause kommst.", a:"Unbekannt" },
];
function _renderWelt() {

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<script src="/js/landing-init.js?v=1278"></script>
<script src="/js/landing-init.js?v=1292"></script>
<title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title>
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, direkt im Browser oder als native iPhone-App (Ban Yaro Go).">
<meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz">

View file

@ -4,7 +4,7 @@
============================================================ */
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab
const VER = '1278';
const VER = '1292';
const CACHE_VERSION = `by-v${VER}`;
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten

View file

@ -14,6 +14,7 @@ services:
- DB_PATH=/data/banyaro.db
- MEDIA_DIR=/data/media
- UMAMI_URL=https://umami.motocamp.de
- KI_MODE=cloud
# VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / VAPID_CONTACT
# → kommen aus .env (nicht in Git)
healthcheck:

View file

@ -13,5 +13,13 @@ for f in tests/js/test-map-offline*.js; do node "$f" backend/static/js/map-offli
- r6: Standort-Grundversorgung (ensureHomeArea: lädt/skippt/Cap, überlebt clear)
- r7: selektives Löschen (Korridor-Keep via keepTracks, manuelle Gebiete weg, Komplett-Wipe-Fallback)
Eigenständig (kein Stub-Argument nötig):
```
node tests/js/test-nav-loop-closestidx.js
```
- nav-loop-closestidx: Navi-Erst-Fix bei Runden springt nicht ans Track-Ende (spiegelt `_closestIdx` aus `js/pages/routes.js`) — Bugfix Angie/Deining 09.06.2026
⚠️ Node 21+: eingebautes `navigator`-Global — Stubs via `Object.defineProperty(globalThis, 'navigator', …)`,
ein einfaches `global.navigator =` wird still verschluckt.

View file

@ -0,0 +1,98 @@
// Navi-Erst-Fix bei RUNDEN: der Startindex darf nicht ans Track-Ende springen.
//
// Spiegelt die _closestIdx-Erst-Fix-Logik aus js/pages/routes.js (_startNav). An einem
// Start/Ende-Knoten einer Runde ist der ENDPUNKT oft ein paar Meter näher als der
// Startpunkt; die alte globale Suche sprang dann sofort ans Track-Ende → 100 % / 0 km ab
// Sekunde 1 (Angie, Deining-Runde 09.06.2026). Bei Änderung BEIDE Stellen anpassen.
//
// Hinweis: bewusst eine Nachbildung — die echte Funktion ist eine Closure in _startNav
// und nicht exportierbar, ohne routes.js umzubauen.
const _haversineKm = (lat1, lon1, lat2, lon2) => {
const R = 6371, dLat = (lat2 - lat1) * Math.PI / 180, dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
};
// Erst-Fix-Index für gegebenen track + Userposition (1:1 aus routes.js).
function firstFixIdx(track, lat, lon) {
const search = (from, to) => {
let best = from, bestD = Infinity;
for (let i = from; i <= to; i++) {
const d = _haversineKm(lat, lon, track[i].lat, track[i].lon);
if (d < bestD) { bestD = d; best = i; }
}
return { best, bestD };
};
const isLoop = track.length > 2 &&
_haversineKm(track[0].lat, track[0].lon,
track[track.length - 1].lat, track[track.length - 1].lon) < 0.06;
const g = search(0, track.length - 1);
if (isLoop) {
const win = Math.min(track.length - 1, Math.max(30, Math.floor(track.length * 0.15)));
const s = search(0, win);
return { idx: s.bestD < 0.15 ? s.best : g.best, isLoop };
}
const s = search(0, Math.min(track.length - 1, 30));
return { idx: (s.bestD - g.bestD) * 1000 < 25 ? s.best : g.best, isLoop };
}
// Die ALTE Logik (vor dem Fix) — nur zum Beweis, dass der Fix wirklich etwas ändert.
function firstFixIdxOld(track, lat, lon) {
const search = (from, to) => {
let best = from, bestD = Infinity;
for (let i = from; i <= to; i++) {
const d = _haversineKm(lat, lon, track[i].lat, track[i].lon);
if (d < bestD) { bestD = d; best = i; }
}
return { best, bestD };
};
const g = search(0, track.length - 1);
const s = search(0, Math.min(track.length - 1, 30));
return (s.bestD - g.bestD) * 1000 < 25 ? s.best : g.best;
}
// --- Synthetische Deining-artige Runde -------------------------------------
const C = { lat: 48.07, lon: 11.50 };
const mLat = m => m / 111320;
const mLon = (m, lat) => m / (111320 * Math.cos(lat * Math.PI / 180));
// Punkt auf einem Kreis: Winkel von Nord, im Uhrzeigersinn.
const onCircle = (deg, r) => {
const rad = deg * Math.PI / 180;
return { lat: C.lat + mLat(r * Math.cos(rad)), lon: C.lon + mLon(r * Math.sin(rad), C.lat) };
};
const N = 40, R = 80; // 40 Punkte auf 80-m-Kreis, lange Runde von 0°→329°
const track = [];
for (let i = 0; i < N; i++) track.push(onCircle(i / (N - 1) * 329, R));
// User steht 3 m außerhalb des ENDpunkts (329°) → näher am Ende als am Start.
const user = onCircle(329, R + 3);
const startEndM = _haversineKm(track[0].lat, track[0].lon,
track[N - 1].lat, track[N - 1].lon) * 1000;
const dStart = _haversineKm(user.lat, user.lon, track[0].lat, track[0].lon) * 1000;
const dEnd = _haversineKm(user.lat, user.lon, track[N - 1].lat, track[N - 1].lon) * 1000;
console.log(`Runde: Start↔Ende ${startEndM.toFixed(0)} m | User→Start ${dStart.toFixed(0)} m, User→Ende ${dEnd.toFixed(0)} m`);
// 1. Loop wird erkannt (Start ≈ Ende < 60 m)
const res = firstFixIdx(track, user.lat, user.lon);
if (!res.isLoop) throw new Error('Runde nicht als Loop erkannt');
// 2. Erst-Fix landet im STARTbereich, NICHT am Track-Ende
console.log('Erst-Fix-Index:', res.idx, '(von', N - 1 + ')');
if (res.idx > Math.floor(N * 0.15)) throw new Error(`Erst-Fix sprang weg vom Start (idx ${res.idx})`);
// 3. Beweis: die alte Logik wäre hier ans Ende gesprungen (100 %)
const old = firstFixIdxOld(track, user.lat, user.lon);
console.log('Alte Logik-Index:', old);
if (old !== N - 1) throw new Error('Erwartet: alte Logik springt ans Ende — Testfall trifft den Bug nicht mehr');
// 4. Punkt-zu-Punkt-Route (kein Loop): User am Start → 0 %, am Ende → bleibt sinnvoll
const ptp = [];
for (let i = 0; i < N; i++) ptp.push({ lat: C.lat + mLat(i * 25), lon: C.lon }); // 25-m-Schritte nach Norden
const ptpRes = firstFixIdx(ptp, ptp[0].lat, ptp[0].lon);
if (ptpRes.isLoop) throw new Error('Gerade Route fälschlich als Loop erkannt');
if (ptpRes.idx !== 0) throw new Error(`Punkt-zu-Punkt am Start sollte idx 0 sein, war ${ptpRes.idx}`);
console.log('\nALLE NAV-LOOP-TESTS BESTANDEN');

View file

@ -59,3 +59,30 @@ def test_delete_account_minimal_user(client):
assert resp.status_code == 200, resp.text
with db() as conn:
assert conn.execute("SELECT 1 FROM users WHERE id=?", (uid,)).fetchone() is None
def test_delete_account_purges_note_media(client):
"""Account-Löschung entfernt Notiz-Medien — DB-Zeilen UND Dateien auf Disk."""
import io, os
from database import db
from PIL import Image
uid, headers = _make_user(client)
nid = client.post("/api/notes/diary/1", headers=headers,
json={"text": "Mit Foto", "parent_label": "X"}).json()["id"]
buf = io.BytesIO(); Image.new("RGB", (10, 10), (1, 2, 3)).save(buf, format="JPEG")
up = client.post(f"/api/notes/{nid}/media", headers=headers,
files={"file": ("f.jpg", buf.getvalue(), "image/jpeg")})
assert up.status_code == 200, up.text
url = up.json()["url"]
fpath = os.path.join(os.getenv("MEDIA_DIR", "/data/media"), url[len("/media/"):])
assert os.path.exists(fpath)
resp = client.delete("/api/profile/account", headers=headers)
assert resp.status_code == 200, resp.text
with db() as conn:
assert conn.execute("SELECT COUNT(*) c FROM note_media WHERE note_id=?", (nid,)).fetchone()["c"] == 0
assert conn.execute("SELECT COUNT(*) c FROM notes WHERE user_id=?", (uid,)).fetchone()["c"] == 0
assert not os.path.exists(fpath), "note_media-Datei blieb als Leiche auf Disk"

129
tests/test_notes_media.py Normal file
View file

@ -0,0 +1,129 @@
"""Tests für Notiz-Medien: Bild-/Audio-Upload, GET liefert media_items,
Löschen entfernt DB-Zeile + Datei, Notiz-Delete räumt Dateien, Validierung."""
import io
import os
import pytest
def _jpeg_bytes(color=(200, 100, 50)):
from PIL import Image
buf = io.BytesIO()
Image.new("RGB", (12, 12), color).save(buf, format="JPEG")
return buf.getvalue()
# Minimaler MP4/M4A-Header (ftyp-Box) — genügt für validate_audio(audio/mp4).
_M4A_BYTES = b"\x00\x00\x00\x18ftypM4A \x00\x00\x00\x00M4A mp42isom" + b"\x00" * 32
def _create_note(client, user, text="Notiz mit Medien"):
r = client.post("/api/notes/diary/1", headers=user["headers"],
json={"text": text, "parent_label": "Testobjekt"})
assert r.status_code == 201, r.text
return r.json()
def _media_path(url):
media_dir = os.getenv("MEDIA_DIR", "/data/media")
rel = url[len("/media/"):] if url.startswith("/media/") else url.lstrip("/")
return os.path.join(media_dir, rel)
def test_upload_image_to_note(client, user):
note = _create_note(client, user)
r = client.post(
f"/api/notes/{note['id']}/media",
headers=user["headers"],
files={"file": ("foto.jpg", _jpeg_bytes(), "image/jpeg")},
)
assert r.status_code == 200, r.text
m = r.json()
assert m["media_type"] == "image"
assert m["url"].startswith("/media/notes/")
assert os.path.exists(_media_path(m["url"]))
def test_note_get_includes_media_items(client, user):
note = _create_note(client, user)
client.post(f"/api/notes/{note['id']}/media", headers=user["headers"],
files={"file": ("a.jpg", _jpeg_bytes(), "image/jpeg")})
r = client.get("/api/notes/diary/1", headers=user["headers"])
assert r.status_code == 200
target = next(n for n in r.json() if n["id"] == note["id"])
assert len(target["media_items"]) == 1
assert target["media_items"][0]["media_type"] == "image"
def test_upload_audio_to_note(client, user):
# Reine Sprachnotiz: leerer Text ist erlaubt, das Medium trägt die Notiz.
note = _create_note(client, user, text="")
r = client.post(
f"/api/notes/{note['id']}/media",
headers=user["headers"],
files={"file": ("sprachnachricht.m4a", _M4A_BYTES, "audio/mp4")},
)
assert r.status_code == 200, r.text
m = r.json()
assert m["media_type"] == "audio"
assert m["url"].endswith(".m4a")
assert os.path.exists(_media_path(m["url"]))
def test_delete_media_removes_row_and_file(client, user):
note = _create_note(client, user)
up = client.post(f"/api/notes/{note['id']}/media", headers=user["headers"],
files={"file": ("x.jpg", _jpeg_bytes(), "image/jpeg")}).json()
path = _media_path(up["url"])
assert os.path.exists(path)
r = client.delete(f"/api/notes/{note['id']}/media/{up['id']}", headers=user["headers"])
assert r.status_code == 204
assert not os.path.exists(path)
g = client.get("/api/notes/diary/1", headers=user["headers"]).json()
target = next(n for n in g if n["id"] == note["id"])
assert target["media_items"] == []
def test_delete_note_removes_media_files(client, user):
note = _create_note(client, user)
up = client.post(f"/api/notes/{note['id']}/media", headers=user["headers"],
files={"file": ("y.jpg", _jpeg_bytes(), "image/jpeg")}).json()
path = _media_path(up["url"])
assert os.path.exists(path)
r = client.delete(f"/api/notes/{note['id']}", headers=user["headers"])
assert r.status_code == 204
assert not os.path.exists(path)
def test_upload_rejects_corrupt_image(client, user):
note = _create_note(client, user)
r = client.post(
f"/api/notes/{note['id']}/media",
headers=user["headers"],
files={"file": ("fake.jpg", b"this is not a jpeg", "image/jpeg")},
)
assert r.status_code == 415
def test_upload_media_requires_own_note(client, user):
r = client.post(
"/api/notes/999999/media",
headers=user["headers"],
files={"file": ("z.jpg", _jpeg_bytes(), "image/jpeg")},
)
assert r.status_code == 404
def test_audio_utils_unit():
"""to_m4a reicht bereits-AAC durch; validate_audio prüft Magic-Bytes."""
from media_utils import to_m4a, validate_audio
data, ext = to_m4a(_M4A_BYTES, ".m4a")
assert ext == ".m4a"
assert data == _M4A_BYTES # schon AAC → keine Transkodierung
validate_audio(_M4A_BYTES, "audio/mp4") # kein Raise
with pytest.raises(ValueError):
validate_audio(b"xxxx", "audio/mp4")