Feature: Trauer-Feature, Futter-Verträglichkeit, Multi-Hund-Fixes, Wetter-Ort (Sprint 47)
- dog-profile.js: Verstorben-Button, Gedenkseite, KI-Abschiedstext - database.py: futter_eintraege/reaktionen, route_dogs, exercise_progress.dog_id - routes/ernaehrung.py: Futter-Verträglichkeit mit 20 Reaktionstypen + Analyse - routes/routen.py: route_dogs Many-to-Many, Routen editierbar - routes/training.py: exercise_progress per dog_id - routes/ki.py: /ki/abschied Trauer-KI - weather.py: Nominatim Ortsname parallel geladen - ui.js: dogChip/bindDogChip, visualViewport-Modal - api.js: gedenken, gedenkseite, futter-Methoden, route_dogs - worlds.js: Ortsname im Wetter-Chip - uebungen.js: _progressLoaded-Flag, dog-spezifischer Fortschritt - trainingsplaene.js: dog_id Unterstützung - diary.js/health.js: P-Badge Cleanup - map.js: Wetter-Ort-Anzeige entfernt - wetter.js: Ort in Wetter-Detail
This commit is contained in:
parent
1ce802c8dc
commit
bda61a0e40
16 changed files with 713 additions and 181 deletions
|
|
@ -189,6 +189,13 @@ def init_db():
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_route_walks_user ON route_walks(user_id);
|
CREATE INDEX IF NOT EXISTS idx_route_walks_user ON route_walks(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS route_dogs (
|
||||||
|
route_id INTEGER NOT NULL REFERENCES routes(id) ON DELETE CASCADE,
|
||||||
|
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (route_id, dog_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_route_dogs_dog ON route_dogs(dog_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS exercise_progress (
|
CREATE TABLE IF NOT EXISTS exercise_progress (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|
@ -1974,6 +1981,38 @@ def _migrate(conn_factory):
|
||||||
""")
|
""")
|
||||||
logger.info("Migration: futter_profil bereit.")
|
logger.info("Migration: futter_profil bereit.")
|
||||||
|
|
||||||
|
# Futter-Einträge & Reaktionen (Verträglichkeits-Tracking)
|
||||||
|
try:
|
||||||
|
conn.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS futter_eintraege (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
|
||||||
|
datum TEXT NOT NULL,
|
||||||
|
uhrzeit TEXT NOT NULL,
|
||||||
|
futter_name TEXT NOT NULL,
|
||||||
|
futter_typ TEXT NOT NULL DEFAULT 'trockenfutter',
|
||||||
|
menge_g INTEGER,
|
||||||
|
notiz TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_futter_eintraege_dog ON futter_eintraege(dog_id, datum DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS futter_reaktionen (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
|
||||||
|
datum TEXT NOT NULL,
|
||||||
|
uhrzeit TEXT NOT NULL,
|
||||||
|
reaktion_typ TEXT NOT NULL,
|
||||||
|
intensitaet INTEGER NOT NULL DEFAULT 3,
|
||||||
|
notiz TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_futter_reaktionen_dog ON futter_reaktionen(dog_id, datum DESC);
|
||||||
|
""")
|
||||||
|
logger.info("Migration: futter_eintraege + futter_reaktionen bereit.")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Migration futter_eintraege/reaktionen: {e}")
|
||||||
|
|
||||||
# Wiederkehrende Ausgaben (Daueraufträge)
|
# Wiederkehrende Ausgaben (Daueraufträge)
|
||||||
conn.executescript("""
|
conn.executescript("""
|
||||||
CREATE TABLE IF NOT EXISTS recurring_expenses (
|
CREATE TABLE IF NOT EXISTS recurring_expenses (
|
||||||
|
|
@ -2104,6 +2143,85 @@ def _migrate(conn_factory):
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Spalte existiert bereits
|
pass # Spalte existiert bereits
|
||||||
|
|
||||||
|
# exercise_progress + training_plan_progress: dog_id ergänzen
|
||||||
|
existing_ep = [r[1] for r in conn.execute("PRAGMA table_info(exercise_progress)").fetchall()]
|
||||||
|
if 'dog_id' not in existing_ep:
|
||||||
|
try:
|
||||||
|
# Neue Tabelle mit dog_id erstellen
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE exercise_progress_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
dog_id INTEGER REFERENCES dogs(id) ON DELETE CASCADE,
|
||||||
|
exercise_id TEXT NOT NULL,
|
||||||
|
status TEXT,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(dog_id, exercise_id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
# Bestehende Daten migrieren: dog_id = erster Hund des Users
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO exercise_progress_new (user_id, dog_id, exercise_id, status, updated_at)
|
||||||
|
SELECT ep.user_id,
|
||||||
|
(SELECT id FROM dogs WHERE user_id=ep.user_id ORDER BY id LIMIT 1),
|
||||||
|
ep.exercise_id, ep.status, ep.updated_at
|
||||||
|
FROM exercise_progress ep
|
||||||
|
""")
|
||||||
|
conn.execute("DROP TABLE exercise_progress")
|
||||||
|
conn.execute("ALTER TABLE exercise_progress_new RENAME TO exercise_progress")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_exercise_progress_user ON exercise_progress(user_id)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_exercise_progress_dog ON exercise_progress(dog_id)")
|
||||||
|
logger.info("Migration: exercise_progress.dog_id hinzugefügt.")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Migration exercise_progress.dog_id fehlgeschlagen: {e}")
|
||||||
|
|
||||||
|
existing_tp = [r[1] for r in conn.execute("PRAGMA table_info(training_plan_progress)").fetchall()]
|
||||||
|
if 'dog_id' not in existing_tp:
|
||||||
|
try:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE training_plan_progress_new (
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
dog_id INTEGER REFERENCES dogs(id) ON DELETE CASCADE,
|
||||||
|
item_key TEXT NOT NULL,
|
||||||
|
checked INTEGER NOT NULL DEFAULT 1,
|
||||||
|
checked_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (dog_id, item_key)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO training_plan_progress_new (user_id, dog_id, item_key, checked, checked_at)
|
||||||
|
SELECT tp.user_id,
|
||||||
|
(SELECT id FROM dogs WHERE user_id=tp.user_id ORDER BY id LIMIT 1),
|
||||||
|
tp.item_key, tp.checked, tp.checked_at
|
||||||
|
FROM training_plan_progress tp
|
||||||
|
""")
|
||||||
|
conn.execute("DROP TABLE training_plan_progress")
|
||||||
|
conn.execute("ALTER TABLE training_plan_progress_new RENAME TO training_plan_progress")
|
||||||
|
logger.info("Migration: training_plan_progress.dog_id hinzugefügt.")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Migration training_plan_progress.dog_id fehlgeschlagen: {e}")
|
||||||
|
|
||||||
|
# verstorben_am: Hund als verstorben markierbar
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE dogs ADD COLUMN verstorben_am TEXT")
|
||||||
|
logger.info("Migration: dogs.verstorben_am hinzugefügt.")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# route_dogs: bestehende Routen allen Hunden des Users zuweisen
|
||||||
|
try:
|
||||||
|
existing = conn.execute("SELECT COUNT(*) FROM route_dogs").fetchone()[0]
|
||||||
|
if existing == 0:
|
||||||
|
conn.execute("""
|
||||||
|
INSERT OR IGNORE INTO route_dogs (route_id, dog_id)
|
||||||
|
SELECT r.id, d.id
|
||||||
|
FROM routes r
|
||||||
|
JOIN dogs d ON d.user_id = r.user_id
|
||||||
|
""")
|
||||||
|
logger.info("Migration: route_dogs mit bestehenden Routen befüllt.")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Migration route_dogs fehlgeschlagen: {e}")
|
||||||
|
|
||||||
|
|
||||||
def _seed_help_articles(conn):
|
def _seed_help_articles(conn):
|
||||||
"""Befüllt help_articles mit Starter-FAQs — nur wenn die Tabelle noch leer ist."""
|
"""Befüllt help_articles mit Starter-FAQs — nur wenn die Tabelle noch leer ist."""
|
||||||
|
|
|
||||||
|
|
@ -361,3 +361,65 @@ Falls kein Hund erkennbar: ist_hund=false und leeres rassen-Array."""
|
||||||
"hinweis": parsed.get("hinweis") or None,
|
"hinweis": parsed.get("hinweis") or None,
|
||||||
"verbleibende_anfragen": remaining_after,
|
"verbleibende_anfragen": remaining_after,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /ki/abschied — Persönlicher Abschiedstext für verstorbenen Hund
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
class AbschiedRequest(BaseModel):
|
||||||
|
dog_id: int
|
||||||
|
name: str
|
||||||
|
rasse: Optional[str] = None
|
||||||
|
km_total: Optional[float] = None
|
||||||
|
diary_count: Optional[int] = None
|
||||||
|
gemeinsam_tage: Optional[int] = None
|
||||||
|
last_entry_titel: Optional[str] = None
|
||||||
|
|
||||||
|
@router.post("/abschied")
|
||||||
|
async def ki_abschied(req: AbschiedRequest, request: Request,
|
||||||
|
user=Depends(get_current_user)):
|
||||||
|
"""Persönlicher Abschiedstext — einmalig generiert, DB-gecacht."""
|
||||||
|
with db() as conn:
|
||||||
|
cached = conn.execute(
|
||||||
|
"SELECT content FROM bday_ki_cache WHERE dog_id=? AND year=9999 AND mode='abschied'",
|
||||||
|
(req.dog_id,)
|
||||||
|
).fetchone()
|
||||||
|
if cached:
|
||||||
|
return {"text": cached["content"], "cached": True}
|
||||||
|
|
||||||
|
name = req.name.strip()[:40]
|
||||||
|
rasse = req.rasse or ""
|
||||||
|
km = f"{req.km_total:.0f} km" if req.km_total else None
|
||||||
|
tage = f"{req.gemeinsam_tage} gemeinsame Tage" if req.gemeinsam_tage else None
|
||||||
|
eintr = f"{req.diary_count} Tagebucheinträge" if req.diary_count else None
|
||||||
|
|
||||||
|
stats_str = ", ".join(filter(None, [km, tage, eintr]))
|
||||||
|
rasse_str = f" ({rasse})" if rasse else ""
|
||||||
|
|
||||||
|
system = (
|
||||||
|
"Du bist ein einfühlsamer Begleiter für Menschen in Trauer um ihren Hund. "
|
||||||
|
"Schreibe warmherzig, persönlich und respektvoll auf Deutsch. "
|
||||||
|
"Keine Floskeln, kein Kitsch — echte Wärme. "
|
||||||
|
"Erwähne die Statistiken natürlich eingebunden."
|
||||||
|
)
|
||||||
|
prompt = (
|
||||||
|
f"{name}{rasse_str} ist über die Regenbogenbrücke gegangen. "
|
||||||
|
f"Schreibe einen kurzen, persönlichen Abschiedstext (ca. 80–100 Wörter) "
|
||||||
|
f"der die Verbundenheit würdigt. "
|
||||||
|
f"Statistiken: {stats_str or 'nicht bekannt'}. "
|
||||||
|
f"Sei warm, nicht sentimental überladen. Schließe mit einem hoffnungsvollen Gedanken."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
text = await ki_module.complete(
|
||||||
|
system=system, prompt=prompt, max_tokens=300,
|
||||||
|
requires_premium=False, user_id=user["id"],
|
||||||
|
)
|
||||||
|
with db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO bday_ki_cache (dog_id, year, mode, content) VALUES (?,9999,'abschied',?)",
|
||||||
|
(req.dog_id, text)
|
||||||
|
)
|
||||||
|
return {"text": text, "cached": False}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(503, str(e))
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ class RouteCreate(BaseModel):
|
||||||
is_public: Optional[bool] = False
|
is_public: Optional[bool] = False
|
||||||
hunde_tauglichkeit: Optional[str] = None # eingeschränkt | gut | sehr_gut | premium
|
hunde_tauglichkeit: Optional[str] = None # eingeschränkt | gut | sehr_gut | premium
|
||||||
client_time: Optional[str] = None
|
client_time: Optional[str] = None
|
||||||
|
dog_ids: Optional[List[int]] = None # Welche Hunde mitgegangen sind
|
||||||
|
|
||||||
class RouteUpdate(BaseModel):
|
class RouteUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
|
|
@ -69,6 +70,9 @@ class RouteUpdate(BaseModel):
|
||||||
is_public: Optional[bool] = None
|
is_public: Optional[bool] = None
|
||||||
hunde_tauglichkeit: Optional[str] = None
|
hunde_tauglichkeit: Optional[str] = None
|
||||||
|
|
||||||
|
class RouteDogs(BaseModel):
|
||||||
|
dog_ids: List[int]
|
||||||
|
|
||||||
|
|
||||||
def _simplify_track(track: list, max_pts: int = 40) -> list:
|
def _simplify_track(track: list, max_pts: int = 40) -> list:
|
||||||
"""Reduziert GPS-Track auf max_pts Punkte für Vorschau."""
|
"""Reduziert GPS-Track auf max_pts Punkte für Vorschau."""
|
||||||
|
|
@ -168,7 +172,26 @@ async def create_route(data: RouteCreate, user=Depends(get_current_user)):
|
||||||
int(data.is_public) if data.is_public is not None else 1,
|
int(data.is_public) if data.is_public is not None else 1,
|
||||||
data.hunde_tauglichkeit, is_valid, ct,
|
data.hunde_tauglichkeit, is_valid, ct,
|
||||||
))
|
))
|
||||||
row = conn.execute("SELECT * FROM routes WHERE id = ?", (cur.lastrowid,)).fetchone()
|
route_id = cur.lastrowid
|
||||||
|
row = conn.execute("SELECT * FROM routes WHERE id = ?", (route_id,)).fetchone()
|
||||||
|
|
||||||
|
# Hunde zuordnen — entweder explizit oder alle Hunde des Users
|
||||||
|
dog_ids = data.dog_ids or []
|
||||||
|
if not dog_ids:
|
||||||
|
# Fallback: alle Hunde des Users
|
||||||
|
all_dogs = conn.execute(
|
||||||
|
"SELECT id FROM dogs WHERE user_id=?", (user['id'],)
|
||||||
|
).fetchall()
|
||||||
|
dog_ids = [d['id'] for d in all_dogs]
|
||||||
|
for did in dog_ids:
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO route_dogs (route_id, dog_id) VALUES (?,?)",
|
||||||
|
(route_id, did)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
update_streak(user['id'], conn)
|
update_streak(user['id'], conn)
|
||||||
check_and_award(user['id'], conn)
|
check_and_award(user['id'], conn)
|
||||||
result = _parse(row)
|
result = _parse(row)
|
||||||
|
|
@ -317,9 +340,14 @@ async def get_route(route_id: int):
|
||||||
"SELECT r.*, u.name AS user_name FROM routes r LEFT JOIN users u ON u.id = r.user_id WHERE r.id = ?",
|
"SELECT r.*, u.name AS user_name FROM routes r LEFT JOIN users u ON u.id = r.user_id WHERE r.id = ?",
|
||||||
(route_id,)
|
(route_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(404, "Route nicht gefunden.")
|
raise HTTPException(404, "Route nicht gefunden.")
|
||||||
return _parse(row)
|
dog_rows = conn.execute(
|
||||||
|
"SELECT dog_id FROM route_dogs WHERE route_id = ?", (route_id,)
|
||||||
|
).fetchall()
|
||||||
|
result = _parse(row)
|
||||||
|
result['dog_ids'] = [r['dog_id'] for r in dog_rows]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -346,6 +374,26 @@ async def update_route(route_id: int, data: RouteUpdate, user=Depends(get_curren
|
||||||
return _parse(row)
|
return _parse(row)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# PATCH /api/routes/{id}/dogs — Hunde der Route aktualisieren
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.patch("/{route_id}/dogs")
|
||||||
|
async def update_route_dogs(route_id: int, data: RouteDogs, user=Depends(get_current_user)):
|
||||||
|
with db() as conn:
|
||||||
|
row = conn.execute("SELECT user_id FROM routes WHERE id = ?", (route_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "Route nicht gefunden.")
|
||||||
|
if row['user_id'] != user['id']:
|
||||||
|
raise HTTPException(403, "Nicht berechtigt.")
|
||||||
|
conn.execute("DELETE FROM route_dogs WHERE route_id = ?", (route_id,))
|
||||||
|
for did in data.dog_ids:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO route_dogs (route_id, dog_id) VALUES (?, ?)",
|
||||||
|
(route_id, did)
|
||||||
|
)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# PATCH /api/routes/{id}/trim — Route kürzen (Datenschutz)
|
# PATCH /api/routes/{id}/trim — Route kürzen (Datenschutz)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -85,28 +85,43 @@ async def update_exercise(exercise_id: int, body: ExerciseUpdate, _=Depends(requ
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
class ProgressUpdate(BaseModel):
|
class ProgressUpdate(BaseModel):
|
||||||
exercise_id: str
|
exercise_id: str
|
||||||
status: Optional[str] = None # null/noch-nicht/manchmal/meistens/sitzt
|
status: Optional[str] = None
|
||||||
|
dog_id: Optional[int] = None
|
||||||
|
|
||||||
@router.get("/progress")
|
@router.get("/progress")
|
||||||
async def get_progress(user=Depends(get_current_user)):
|
async def get_progress(dog_id: Optional[int] = None, user=Depends(get_current_user)):
|
||||||
uid = user["id"]
|
uid = user["id"]
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
rows = conn.execute(
|
if dog_id:
|
||||||
"SELECT exercise_id, status, updated_at FROM exercise_progress WHERE user_id=?",
|
rows = conn.execute(
|
||||||
(uid,)
|
"SELECT exercise_id, status, updated_at FROM exercise_progress WHERE dog_id=?",
|
||||||
).fetchall()
|
(dog_id,)
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT exercise_id, status, updated_at FROM exercise_progress WHERE user_id=?",
|
||||||
|
(uid,)
|
||||||
|
).fetchall()
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
@router.post("/progress")
|
@router.post("/progress")
|
||||||
async def upsert_progress(body: ProgressUpdate, user=Depends(get_current_user)):
|
async def upsert_progress(body: ProgressUpdate, user=Depends(get_current_user)):
|
||||||
uid = user["id"]
|
uid = user["id"]
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
conn.execute("""
|
if body.dog_id:
|
||||||
INSERT INTO exercise_progress (user_id, exercise_id, status)
|
conn.execute("""
|
||||||
VALUES (?,?,?)
|
INSERT INTO exercise_progress (user_id, dog_id, exercise_id, status)
|
||||||
ON CONFLICT(user_id, exercise_id) DO UPDATE
|
VALUES (?,?,?,?)
|
||||||
SET status=excluded.status, updated_at=datetime('now')
|
ON CONFLICT(dog_id, exercise_id) DO UPDATE
|
||||||
""", (uid, body.exercise_id, body.status))
|
SET status=excluded.status, updated_at=datetime('now')
|
||||||
|
""", (uid, body.dog_id, body.exercise_id, body.status))
|
||||||
|
else:
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO exercise_progress (user_id, exercise_id, status)
|
||||||
|
VALUES (?,?,?)
|
||||||
|
ON CONFLICT(dog_id, exercise_id) DO UPDATE
|
||||||
|
SET status=excluded.status, updated_at=datetime('now')
|
||||||
|
""", (uid, body.exercise_id, body.status))
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -115,15 +130,22 @@ async def upsert_progress(body: ProgressUpdate, user=Depends(get_current_user)):
|
||||||
class PlanProgress(BaseModel):
|
class PlanProgress(BaseModel):
|
||||||
item_key: str
|
item_key: str
|
||||||
checked: bool
|
checked: bool
|
||||||
|
dog_id: Optional[int] = None
|
||||||
|
|
||||||
@router.get("/plan-progress")
|
@router.get("/plan-progress")
|
||||||
async def get_plan_progress(user=Depends(get_current_user)):
|
async def get_plan_progress(dog_id: Optional[int] = None, user=Depends(get_current_user)):
|
||||||
uid = user["id"]
|
uid = user["id"]
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
rows = conn.execute(
|
if dog_id:
|
||||||
"SELECT item_key, checked FROM training_plan_progress WHERE user_id=?",
|
rows = conn.execute(
|
||||||
(uid,)
|
"SELECT item_key, checked FROM training_plan_progress WHERE dog_id=?",
|
||||||
).fetchall()
|
(dog_id,)
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT item_key, checked FROM training_plan_progress WHERE user_id=?",
|
||||||
|
(uid,)
|
||||||
|
).fetchall()
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
@router.post("/plan-progress")
|
@router.post("/plan-progress")
|
||||||
|
|
@ -132,13 +154,13 @@ async def upsert_plan_progress(body: PlanProgress, user=Depends(get_current_user
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
if body.checked:
|
if body.checked:
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
INSERT OR REPLACE INTO training_plan_progress (user_id, item_key, checked)
|
INSERT OR REPLACE INTO training_plan_progress (user_id, dog_id, item_key, checked)
|
||||||
VALUES (?,?,1)
|
VALUES (?,?,?,1)
|
||||||
""", (uid, body.item_key))
|
""", (uid, body.dog_id, body.item_key))
|
||||||
else:
|
else:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"DELETE FROM training_plan_progress WHERE user_id=? AND item_key=?",
|
"DELETE FROM training_plan_progress WHERE dog_id=? AND item_key=?",
|
||||||
(uid, body.item_key)
|
(body.dog_id, body.item_key)
|
||||||
)
|
)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
@ -149,13 +171,19 @@ GRUNDKOMMANDOS_ORDER = ['Sitz', 'Platz', 'Bleib', 'Hier / Komm', 'Fuß', 'Aus /
|
||||||
TRICKS_FIRST = ['Pfote / Schütteln', 'Dreh', 'Auf die Decke', 'Nasenarbeit / Suchen']
|
TRICKS_FIRST = ['Pfote / Schütteln', 'Dreh', 'Auf die Decke', 'Nasenarbeit / Suchen']
|
||||||
|
|
||||||
@router.get("/suggestions")
|
@router.get("/suggestions")
|
||||||
async def get_suggestions(user=Depends(get_current_user)):
|
async def get_suggestions(dog_id: Optional[int] = None, user=Depends(get_current_user)):
|
||||||
uid = user["id"]
|
uid = user["id"]
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
rows = conn.execute(
|
if dog_id:
|
||||||
"SELECT exercise_id, status FROM exercise_progress WHERE user_id=?",
|
rows = conn.execute(
|
||||||
(uid,)
|
"SELECT exercise_id, status FROM exercise_progress WHERE dog_id=?",
|
||||||
).fetchall()
|
(dog_id,)
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT exercise_id, status FROM exercise_progress WHERE user_id=?",
|
||||||
|
(uid,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
progress = {r["exercise_id"]: r["status"] for r in rows}
|
progress = {r["exercise_id"]: r["status"] for r in rows}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,15 @@ const API = (() => {
|
||||||
deletePhoto(id) { return del(`/dogs/${id}/photo`); },
|
deletePhoto(id) { return del(`/dogs/${id}/photo`); },
|
||||||
getSkills(id) { return get(`/dogs/${id}/skills`); },
|
getSkills(id) { return get(`/dogs/${id}/skills`); },
|
||||||
welcomeDashboard(dogId) { return get(`/dogs/${dogId}/welcome-dashboard`); },
|
welcomeDashboard(dogId) { return get(`/dogs/${dogId}/welcome-dashboard`); },
|
||||||
|
gedenken(id, datum) { return post(`/dogs/${id}/gedenken`, { verstorben_am: datum }); },
|
||||||
|
gedenkseite(id) { return get(`/dogs/${id}/gedenkseite`); },
|
||||||
|
futterList(id) { return get(`/dogs/${id}/futter`); },
|
||||||
|
futterCreate(id, data) { return post(`/dogs/${id}/futter`, data); },
|
||||||
|
futterDelete(id, eid) { return del(`/dogs/${id}/futter/${eid}`); },
|
||||||
|
reaktionList(id) { return get(`/dogs/${id}/futter/reaktionen`); },
|
||||||
|
reaktionCreate(id, d) { return post(`/dogs/${id}/futter/reaktion`, d); },
|
||||||
|
reaktionDelete(id, rid) { return del(`/dogs/${id}/futter/reaktion/${rid}`); },
|
||||||
|
futterAnalyse(id) { return get(`/dogs/${id}/futter/analyse`); },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -287,6 +296,7 @@ const API = (() => {
|
||||||
rate(id, wertung) { return post(`/routes/${id}/rate`, { wertung }); },
|
rate(id, wertung) { return post(`/routes/${id}/rate`, { wertung }); },
|
||||||
walked(id, walked_km, progress_pct) { return post(`/routes/${id}/walked`, { walked_km, progress_pct }); },
|
walked(id, walked_km, progress_pct) { return post(`/routes/${id}/walked`, { walked_km, progress_pct }); },
|
||||||
reverse(id) { return post(`/routes/${id}/reverse`, {}); },
|
reverse(id) { return post(`/routes/${id}/reverse`, {}); },
|
||||||
|
updateDogs(id, dog_ids) { return patch(`/routes/${id}/dogs`, { dog_ids }); },
|
||||||
addPhoto(id, file) {
|
addPhoto(id, file) {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', file);
|
fd.append('file', file);
|
||||||
|
|
@ -300,11 +310,11 @@ const API = (() => {
|
||||||
// TRAINING & ÜBUNGSFORTSCHRITT
|
// TRAINING & ÜBUNGSFORTSCHRITT
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
const training = {
|
const training = {
|
||||||
getProgress() { return get('/training/progress'); },
|
getProgress(dogId) { return get(`/training/progress${dogId ? `?dog_id=${dogId}` : ''}`); },
|
||||||
setProgress(id, status) { return post('/training/progress', { exercise_id: id, status }); },
|
setProgress(id, status, dogId){ return post('/training/progress', { exercise_id: id, status, dog_id: dogId || null }); },
|
||||||
getSuggestions() { return get('/training/suggestions'); },
|
getSuggestions(dogId) { return get(`/training/suggestions${dogId ? `?dog_id=${dogId}` : ''}`); },
|
||||||
getPlanProgress() { return get('/training/plan-progress'); },
|
getPlanProgress(dogId) { return get(`/training/plan-progress${dogId ? `?dog_id=${dogId}` : ''}`); },
|
||||||
setPlanProgress(key, checked) { return post('/training/plan-progress', { item_key: key, checked }); },
|
setPlanProgress(key, checked, dogId) { return post('/training/plan-progress', { item_key: key, checked, dog_id: dogId || null }); },
|
||||||
getRecommendations(dogId) { return get(`/training/recommendations?dog_id=${dogId}`); },
|
getRecommendations(dogId) { return get(`/training/recommendations?dog_id=${dogId}`); },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -149,12 +149,6 @@ window.Page_diary = (() => {
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
if (!_appState.activeDog) return;
|
if (!_appState.activeDog) return;
|
||||||
// Mehrere Hunde → Picker zeigen (User kann Hund wählen)
|
|
||||||
if (_appState.dogs.length > 1) {
|
|
||||||
_renderDogPicker();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Einzelner Hund → Diary direkt neu laden
|
|
||||||
_offset = 0;
|
_offset = 0;
|
||||||
_entries = [];
|
_entries = [];
|
||||||
_totalStats = null;
|
_totalStats = null;
|
||||||
|
|
@ -194,52 +188,7 @@ window.Page_diary = (() => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_appState.dogs.length > 1) {
|
await _renderDiary();
|
||||||
_renderDogPicker();
|
|
||||||
} else {
|
|
||||||
await _renderDiary();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// HUNDE-PICKER — Einstiegsseite bei mehreren Hunden
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
function _renderDogPicker() {
|
|
||||||
const activeDogId = _appState.activeDog?.id;
|
|
||||||
|
|
||||||
const cards = _appState.dogs.map(dog => {
|
|
||||||
const isActive = dog.id === activeDogId;
|
|
||||||
const av = dog.foto_url
|
|
||||||
? `<img src="${UI.escape(dog.foto_url)}" alt="${UI.escape(dog.name)}">`
|
|
||||||
: `<span>${UI.icon('dog')}</span>`;
|
|
||||||
return `
|
|
||||||
<div class="diary-picker-card${isActive ? ' diary-picker-card--active' : ''}"
|
|
||||||
data-dog-id="${dog.id}">
|
|
||||||
<div class="diary-picker-av">${av}</div>
|
|
||||||
<div class="diary-picker-name">${UI.escape(dog.name)}</div>
|
|
||||||
${dog.rasse ? `<div class="diary-picker-rasse">${UI.escape(dog.rasse)}</div>` : ''}
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
_container.innerHTML = `
|
|
||||||
<div class="diary-picker-wrap">
|
|
||||||
<p class="diary-picker-hint">Wessen Tagebuch?</p>
|
|
||||||
<div class="diary-picker-grid">${cards}</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
_container.querySelectorAll('.diary-picker-card').forEach(el => {
|
|
||||||
el.addEventListener('click', async () => {
|
|
||||||
const id = parseInt(el.dataset.dogId);
|
|
||||||
if (id === _appState.activeDog?.id) {
|
|
||||||
// Bereits aktiver Hund → direkt Diary laden
|
|
||||||
_offset = 0; _entries = [];
|
|
||||||
await _renderDiary();
|
|
||||||
} else {
|
|
||||||
App.setActiveDog(id);
|
|
||||||
// onDogChange() → _renderDiary() via _notifyDogChange()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -247,6 +196,7 @@ window.Page_diary = (() => {
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
async function _renderDiary() {
|
async function _renderDiary() {
|
||||||
_container.innerHTML = `
|
_container.innerHTML = `
|
||||||
|
${UI.dogChip(_appState)}
|
||||||
<div class="by-toolbar diary-toolbar">
|
<div class="by-toolbar diary-toolbar">
|
||||||
<div class="diary-search-wrap" id="diary-search-wrap">
|
<div class="diary-search-wrap" id="diary-search-wrap">
|
||||||
<svg class="ph-icon diary-search-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
|
<svg class="ph-icon diary-search-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
|
||||||
|
|
@ -274,6 +224,7 @@ window.Page_diary = (() => {
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
|
UI.bindDogChip(_container, _appState);
|
||||||
|
|
||||||
_container.querySelector('#diary-milestone-filter')
|
_container.querySelector('#diary-milestone-filter')
|
||||||
?.addEventListener('click', async () => {
|
?.addEventListener('click', async () => {
|
||||||
|
|
|
||||||
|
|
@ -1060,6 +1060,12 @@ window.Page_dog_profile = (() => {
|
||||||
<button type="submit" form="dp-form" class="btn btn-primary" style="width:100%">Speichern</button>
|
<button type="submit" form="dp-form" class="btn btn-primary" style="width:100%">Speichern</button>
|
||||||
<div style="display:flex;gap:var(--space-2)">
|
<div style="display:flex;gap:var(--space-2)">
|
||||||
<button type="button" class="btn btn-danger" id="dp-delete-btn">Löschen</button>
|
<button type="button" class="btn btn-danger" id="dp-delete-btn">Löschen</button>
|
||||||
|
<button type="button" id="dp-gedenken-btn"
|
||||||
|
style="flex:1;padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
|
||||||
|
border:none;background:#1a1a1a;color:#C4843A;
|
||||||
|
font-size:var(--text-sm);font-weight:600;cursor:pointer">
|
||||||
|
Verstorben
|
||||||
|
</button>
|
||||||
<button type="button" class="btn btn-secondary flex-1" id="dp-form-cancel">Abbrechen</button>
|
<button type="button" class="btn btn-secondary flex-1" id="dp-form-cancel">Abbrechen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1279,6 +1285,11 @@ window.Page_dog_profile = (() => {
|
||||||
document.getElementById('dp-form-cancel')
|
document.getElementById('dp-form-cancel')
|
||||||
?.addEventListener('click', UI.modal.close);
|
?.addEventListener('click', UI.modal.close);
|
||||||
|
|
||||||
|
document.getElementById('dp-gedenken-btn')?.addEventListener('click', async () => {
|
||||||
|
UI.modal.close();
|
||||||
|
_openGedenkenFlow(dog);
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('dp-delete-btn')?.addEventListener('click', async () => {
|
document.getElementById('dp-delete-btn')?.addEventListener('click', async () => {
|
||||||
const ok = await UI.modal.confirm({
|
const ok = await UI.modal.confirm({
|
||||||
title : `${dog.name} löschen?`,
|
title : `${dog.name} löschen?`,
|
||||||
|
|
@ -2414,6 +2425,178 @@ window.Page_dog_profile = (() => {
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// PUBLIC
|
// PUBLIC
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// GEDENKEN-FLOW
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
async function _openGedenkenFlow(dog) {
|
||||||
|
// Schritt 1: Würdevoller Übergangsdialog mit Datum-Eingabe
|
||||||
|
UI.modal.open({
|
||||||
|
title: `Abschied von ${dog.name}`,
|
||||||
|
body: `
|
||||||
|
<div style="text-align:center;padding:var(--space-2) 0 var(--space-4)">
|
||||||
|
<svg class="ph-icon" style="width:48px;height:48px;color:var(--c-primary);opacity:0.7" aria-hidden="true">
|
||||||
|
<use href="/icons/phosphor.svg#heart"></use>
|
||||||
|
</svg>
|
||||||
|
<p style="color:var(--c-text-secondary);margin:var(--space-3) 0 var(--space-4);line-height:1.6">
|
||||||
|
${dog.name} hinterlässt eine riesige Lücke.<br>
|
||||||
|
Die gemeinsamen Erinnerungen bleiben für immer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form id="gedenken-form">
|
||||||
|
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
|
||||||
|
Datum des Abschieds
|
||||||
|
</label>
|
||||||
|
<input type="date" id="gedenken-datum" name="datum"
|
||||||
|
value="${new Date().toISOString().slice(0,10)}"
|
||||||
|
max="${new Date().toISOString().slice(0,10)}"
|
||||||
|
style="width:100%;padding:10px 12px;border:1.5px solid var(--c-border);border-radius:var(--radius-md);
|
||||||
|
background:var(--c-bg-card);color:var(--c-text);font-size:var(--text-sm);box-sizing:border-box">
|
||||||
|
</form>`,
|
||||||
|
footer: `
|
||||||
|
<div class="w3-btn-stack">
|
||||||
|
<button type="submit" form="gedenken-form" id="gedenken-save-btn" class="btn btn-primary" style="width:100%">
|
||||||
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#heart"></use></svg>
|
||||||
|
Gedenkseite erstellen
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
|
||||||
|
</div>`,
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('gedenken-form')?.addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = document.getElementById('gedenken-save-btn');
|
||||||
|
const datum = document.getElementById('gedenken-datum').value;
|
||||||
|
await UI.asyncButton(btn, async () => {
|
||||||
|
await API.post(`/dogs/${dog.id}/gedenken`, { verstorben_am: datum });
|
||||||
|
// Aus aktiver Hundeliste entfernen
|
||||||
|
_appState.dogs = _appState.dogs.filter(d => d.id !== dog.id);
|
||||||
|
_appState.activeDog = _appState.dogs[0] || null;
|
||||||
|
UI.modal.close();
|
||||||
|
// Gedenkseite öffnen
|
||||||
|
await _openGedenkseite(dog.id, dog.name);
|
||||||
|
await _render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _openGedenkseite(dogId, dogName) {
|
||||||
|
UI.modal.open({ title: `Erinnerungen an ${dogName}`, body: `
|
||||||
|
<div style="text-align:center;padding:var(--space-4)">
|
||||||
|
<svg class="ph-icon" style="width:32px;height:32px;color:var(--c-primary);animation:spin 1s linear infinite">
|
||||||
|
<use href="/icons/phosphor.svg#spinner"></use>
|
||||||
|
</svg>
|
||||||
|
</div>` });
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try { data = await API.get(`/dogs/${dogId}/gedenkseite`); }
|
||||||
|
catch { UI.modal.close(); return; }
|
||||||
|
|
||||||
|
const d = data;
|
||||||
|
const av = d.dog.foto_url
|
||||||
|
? `<img src="${UI.escape(d.dog.foto_url)}" style="width:100px;height:100px;border-radius:50%;object-fit:cover;border:3px solid var(--c-primary)">`
|
||||||
|
: `<div style="width:100px;height:100px;border-radius:50%;background:var(--c-primary-subtle);display:flex;align-items:center;justify-content:center;border:3px solid var(--c-primary)"><svg class="ph-icon" style="width:48px;height:48px;color:var(--c-primary)"><use href="/icons/phosphor.svg#dog"></use></svg></div>`;
|
||||||
|
|
||||||
|
const photoGrid = d.photos.length ? `
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:4px;margin:var(--space-4) 0">
|
||||||
|
${d.photos.map(url => `<img src="${UI.escape(url)}" style="width:100%;aspect-ratio:1;object-fit:cover;border-radius:6px">`).join('')}
|
||||||
|
</div>` : '';
|
||||||
|
|
||||||
|
const statsHtml = `
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);margin:var(--space-4) 0">
|
||||||
|
${d.km_total ? `<div class="card" style="padding:var(--space-3);text-align:center">
|
||||||
|
<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#path"></use></svg>
|
||||||
|
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.km_total}</div>
|
||||||
|
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">km zusammen</div>
|
||||||
|
</div>` : ''}
|
||||||
|
${d.diary_count ? `<div class="card" style="padding:var(--space-3);text-align:center">
|
||||||
|
<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>
|
||||||
|
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.diary_count}</div>
|
||||||
|
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Tagebucheinträge</div>
|
||||||
|
</div>` : ''}
|
||||||
|
${d.media_count ? `<div class="card" style="padding:var(--space-3);text-align:center">
|
||||||
|
<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#images"></use></svg>
|
||||||
|
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.media_count}</div>
|
||||||
|
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Fotos</div>
|
||||||
|
</div>` : ''}
|
||||||
|
${d.gemeinsam_tage ? `<div class="card" style="padding:var(--space-3);text-align:center">
|
||||||
|
<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#calendar-heart"></use></svg>
|
||||||
|
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.gemeinsam_tage}</div>
|
||||||
|
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">gemeinsame Tage</div>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// Trauer-Support-Texte
|
||||||
|
const supportHtml = `
|
||||||
|
<div style="background:var(--c-primary-subtle);border-left:3px solid var(--c-primary);
|
||||||
|
border-radius:0 var(--radius-md) var(--radius-md) 0;padding:var(--space-4);margin:var(--space-4) 0">
|
||||||
|
<div style="font-weight:700;margin-bottom:var(--space-2);display:flex;align-items:center;gap:var(--space-2)">
|
||||||
|
<svg class="ph-icon" style="width:18px;height:18px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#heartbeat"></use></svg>
|
||||||
|
Für dich in dieser Zeit
|
||||||
|
</div>
|
||||||
|
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0;line-height:1.6">
|
||||||
|
Der Schmerz über den Verlust eines Hundes ist real und tief. Du musst nicht stark sein.
|
||||||
|
Lass dich trauern — so lange du brauchst. Die Erinnerungen bleiben immer bei dir.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.8">
|
||||||
|
<div style="display:flex;align-items:flex-start;gap:var(--space-2);margin-bottom:var(--space-2)">
|
||||||
|
<svg class="ph-icon" style="width:16px;height:16px;flex-shrink:0;margin-top:3px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#heart"></use></svg>
|
||||||
|
Sprich mit Freunden oder der Familie über ${d.dog.name} — Geschichten lebendig halten hilft.
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:flex-start;gap:var(--space-2);margin-bottom:var(--space-2)">
|
||||||
|
<svg class="ph-icon" style="width:16px;height:16px;flex-shrink:0;margin-top:3px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>
|
||||||
|
Das Tagebuch bleibt erhalten — es ist ein kostbares Stück gemeinsamer Geschichte.
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:flex-start;gap:var(--space-2)">
|
||||||
|
<svg class="ph-icon" style="width:16px;height:16px;flex-shrink:0;margin-top:3px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#chat-circle-dots"></use></svg>
|
||||||
|
Professionelle Hilfe bei Tiertrauer: <strong>Tiertrauer-Hotline 0800 111 0 111</strong> (kostenlos)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="gedenk-ki-wrap" style="margin-top:var(--space-4)">
|
||||||
|
<button id="gedenk-ki-btn" class="btn btn-secondary" style="width:100%">
|
||||||
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sparkle"></use></svg>
|
||||||
|
Persönlichen Abschiedstext erstellen
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const modal = UI.modal.open({
|
||||||
|
title: `🌈 Erinnerungen an ${UI.escape(d.dog.name)}`,
|
||||||
|
body: `
|
||||||
|
<div style="text-align:center;margin-bottom:var(--space-4)">
|
||||||
|
${av}
|
||||||
|
<div style="font-size:var(--text-xl);font-weight:800;margin-top:var(--space-3)">${UI.escape(d.dog.name)}</div>
|
||||||
|
${d.dog.rasse ? `<div style="color:var(--c-text-secondary);font-size:var(--text-sm)">${UI.escape(d.dog.rasse)}</div>` : ''}
|
||||||
|
${d.dog.verstorben_am ? `<div style="color:var(--c-text-muted);font-size:var(--text-xs);margin-top:4px">
|
||||||
|
<svg class="ph-icon" style="width:12px;height:12px" aria-hidden="true"><use href="/icons/phosphor.svg#rainbow"></use></svg>
|
||||||
|
Über die Regenbogenbrücke am ${new Date(d.dog.verstorben_am).toLocaleDateString('de-DE')}
|
||||||
|
</div>` : ''}
|
||||||
|
</div>
|
||||||
|
${photoGrid}
|
||||||
|
${statsHtml}
|
||||||
|
${supportHtml}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('gedenk-ki-btn')?.addEventListener('click', async () => {
|
||||||
|
const btn = document.getElementById('gedenk-ki-btn');
|
||||||
|
await UI.asyncButton(btn, async () => {
|
||||||
|
const result = await API.post('/ki/abschied', {
|
||||||
|
dog_id: dogId,
|
||||||
|
name: d.dog.name,
|
||||||
|
rasse: d.dog.rasse,
|
||||||
|
km_total: d.km_total,
|
||||||
|
diary_count: d.diary_count,
|
||||||
|
gemeinsam_tage: d.gemeinsam_tage,
|
||||||
|
});
|
||||||
|
const wrap = document.getElementById('gedenk-ki-wrap');
|
||||||
|
if (wrap) wrap.innerHTML = `
|
||||||
|
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-4);
|
||||||
|
font-size:var(--text-sm);line-height:1.7;color:var(--c-text);font-style:italic">
|
||||||
|
"${UI.escape(result.text)}"
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return { init, refresh, onDogChange, addNew: _openCreateModal };
|
return { init, refresh, onDogChange, addNew: _openCreateModal };
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -50,10 +50,6 @@ window.Page_health = (() => {
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
if (!_appState.activeDog) return;
|
if (!_appState.activeDog) return;
|
||||||
if (_appState.dogs.length > 1) {
|
|
||||||
_renderDogPicker();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_data = {};
|
_data = {};
|
||||||
await _renderHealth();
|
await _renderHealth();
|
||||||
}
|
}
|
||||||
|
|
@ -81,52 +77,7 @@ window.Page_health = (() => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_appState.dogs.length > 1) {
|
await _renderHealth();
|
||||||
_renderDogPicker();
|
|
||||||
} else {
|
|
||||||
await _renderHealth();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// HUNDE-PICKER
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
function _renderDogPicker() {
|
|
||||||
const activeDogId = _appState.activeDog?.id;
|
|
||||||
|
|
||||||
const cards = _appState.dogs.map(dog => {
|
|
||||||
const isActive = dog.id === activeDogId;
|
|
||||||
const av = dog.foto_url
|
|
||||||
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}">`
|
|
||||||
: `<span>${UI.icon('dog')}</span>`;
|
|
||||||
return `
|
|
||||||
<div class="diary-picker-card${isActive ? ' diary-picker-card--active' : ''}"
|
|
||||||
data-dog-id="${dog.id}">
|
|
||||||
<div class="diary-picker-av">${av}</div>
|
|
||||||
<div class="diary-picker-name">${_esc(dog.name)}</div>
|
|
||||||
${dog.rasse ? `<div class="diary-picker-rasse">${_esc(dog.rasse)}</div>` : ''}
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
_container.innerHTML = `
|
|
||||||
<div class="diary-picker-wrap">
|
|
||||||
<p class="diary-picker-hint">Wessen Gesundheitsakte?</p>
|
|
||||||
<div class="diary-picker-grid">${cards}</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
_container.querySelectorAll('.diary-picker-card').forEach(el => {
|
|
||||||
el.addEventListener('click', async () => {
|
|
||||||
const id = parseInt(el.dataset.dogId);
|
|
||||||
if (id === _appState.activeDog?.id) {
|
|
||||||
// Bereits aktiver Hund → direkt Health laden
|
|
||||||
_data = {};
|
|
||||||
await _renderHealth();
|
|
||||||
} else {
|
|
||||||
App.setActiveDog(id);
|
|
||||||
// onDogChange() → _renderHealth() via _notifyDogChange()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -147,6 +98,7 @@ window.Page_health = (() => {
|
||||||
</button>
|
</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
_container.innerHTML = `
|
_container.innerHTML = `
|
||||||
|
${UI.dogChip(_appState)}
|
||||||
<div class="by-toolbar health-header">
|
<div class="by-toolbar health-header">
|
||||||
<button class="btn btn-secondary btn-sm" id="health-ki-btn">
|
<button class="btn btn-secondary btn-sm" id="health-ki-btn">
|
||||||
${UI.icon('star')} KI-Zusammenfassung
|
${UI.icon('star')} KI-Zusammenfassung
|
||||||
|
|
@ -164,6 +116,7 @@ window.Page_health = (() => {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
_renderTabBar();
|
_renderTabBar();
|
||||||
|
UI.bindDogChip(_container, _appState);
|
||||||
_container.querySelector('#health-ki-btn')
|
_container.querySelector('#health-ki-btn')
|
||||||
.addEventListener('click', _showKiSummary);
|
.addEventListener('click', _showKiSummary);
|
||||||
_container.querySelector('#health-ki-tierarzt-btn')
|
_container.querySelector('#health-ki-tierarzt-btn')
|
||||||
|
|
|
||||||
|
|
@ -1688,11 +1688,34 @@ window.Page_map = (() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function _showRecSaveModal(track, distKm, dauMin) {
|
function _showRecSaveModal(track, distKm, dauMin) {
|
||||||
|
const dogs = _appState?.dogs || [];
|
||||||
|
const activeDogId = _appState?.activeDog?.id;
|
||||||
|
const dogPickerHtml = dogs.length > 1 ? `
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Welche Hunde waren dabei?</label>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)">
|
||||||
|
${dogs.map(d => {
|
||||||
|
const checked = d.id === activeDogId;
|
||||||
|
const av = d.foto_url
|
||||||
|
? `<img src="${UI.escape(d.foto_url)}" style="width:20px;height:20px;border-radius:50%;object-fit:cover;flex-shrink:0">`
|
||||||
|
: `<svg class="ph-icon" style="width:14px;height:14px;flex-shrink:0"><use href="/icons/phosphor.svg#dog"></use></svg>`;
|
||||||
|
return `<label style="display:inline-flex;align-items:center;gap:6px;padding:5px 10px;
|
||||||
|
border:1.5px solid var(--c-border);border-radius:100px;cursor:pointer;
|
||||||
|
font-size:var(--text-xs);font-weight:600;user-select:none">
|
||||||
|
<input type="checkbox" name="dog_ids" value="${d.id}" ${checked ? 'checked' : ''}
|
||||||
|
style="display:none" class="rec-dog-cb">
|
||||||
|
${av}<span>${UI.escape(d.name)}</span>
|
||||||
|
</label>`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
</div>` : '';
|
||||||
|
|
||||||
const body = `
|
const body = `
|
||||||
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
|
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
|
||||||
${track.length} GPS-Punkte · ${distKm.toFixed(2)} km · ca. ${dauMin} min
|
${track.length} GPS-Punkte · ${distKm.toFixed(2)} km · ca. ${dauMin} min
|
||||||
</p>
|
</p>
|
||||||
<form id="rec-save-form" autocomplete="off">
|
<form id="rec-save-form" autocomplete="off">
|
||||||
|
${dogPickerHtml}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Name der Route *</label>
|
<label class="form-label">Name der Route *</label>
|
||||||
<input class="form-control" type="text" name="name"
|
<input class="form-control" type="text" name="name"
|
||||||
|
|
@ -1772,10 +1795,23 @@ window.Page_map = (() => {
|
||||||
if (_recMarker) { _recMarker.remove(); _recMarker = null; }
|
if (_recMarker) { _recMarker.remove(); _recMarker = null; }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Hund-Checkbox Toggle-Styling
|
||||||
|
document.querySelectorAll('.rec-dog-cb').forEach(cb => {
|
||||||
|
const label = cb.closest('label');
|
||||||
|
const update = () => {
|
||||||
|
label.style.borderColor = cb.checked ? 'var(--c-primary)' : 'var(--c-border)';
|
||||||
|
label.style.background = cb.checked ? 'var(--c-primary-subtle)' : '';
|
||||||
|
label.style.color = cb.checked ? 'var(--c-primary)' : '';
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
cb.addEventListener('change', update);
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('rec-save-form')?.addEventListener('submit', async e => {
|
document.getElementById('rec-save-form')?.addEventListener('submit', async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const btn = document.querySelector('[form="rec-save-form"][type="submit"]');
|
const btn = document.querySelector('[form="rec-save-form"][type="submit"]');
|
||||||
const fd = UI.formData(e.target);
|
const fd = UI.formData(e.target);
|
||||||
|
const dogIds = [...document.querySelectorAll('.rec-dog-cb:checked')].map(c => parseInt(c.value));
|
||||||
await UI.asyncButton(btn, async () => {
|
await UI.asyncButton(btn, async () => {
|
||||||
const saved = await API.routes.create({
|
const saved = await API.routes.create({
|
||||||
name: fd.name?.trim(),
|
name: fd.name?.trim(),
|
||||||
|
|
@ -1789,6 +1825,7 @@ window.Page_map = (() => {
|
||||||
leine_empfohlen: 'leine_empfohlen' in fd,
|
leine_empfohlen: 'leine_empfohlen' in fd,
|
||||||
is_public: 'is_public' in fd,
|
is_public: 'is_public' in fd,
|
||||||
hunde_tauglichkeit: fd.hunde_tauglichkeit || 'sehr_gut',
|
hunde_tauglichkeit: fd.hunde_tauglichkeit || 'sehr_gut',
|
||||||
|
dog_ids: dogIds.length ? dogIds : null,
|
||||||
});
|
});
|
||||||
UI.modal.close();
|
UI.modal.close();
|
||||||
if (_recPolyline) { _recPolyline.remove(); _recPolyline = null; }
|
if (_recPolyline) { _recPolyline.remove(); _recPolyline = null; }
|
||||||
|
|
|
||||||
|
|
@ -2156,6 +2156,7 @@ window.Page_routes = (() => {
|
||||||
${_actionBtn('rd-send-friend', 'paper-plane-tilt', 'Senden')}
|
${_actionBtn('rd-send-friend', 'paper-plane-tilt', 'Senden')}
|
||||||
${track.length >= 4 ? _actionBtn('rd-trim', 'pencil-simple', 'Kürzen') : ''}
|
${track.length >= 4 ? _actionBtn('rd-trim', 'pencil-simple', 'Kürzen') : ''}
|
||||||
${_actionBtn('rd-reverse', 'path', 'Umkehren')}
|
${_actionBtn('rd-reverse', 'path', 'Umkehren')}
|
||||||
|
${(_appState?.dogs?.length > 0) ? _actionBtn('rd-dogs', 'dog', 'Hunde') : ''}
|
||||||
${_actionBtn('rd-del', 'trash', 'Löschen', true)}
|
${_actionBtn('rd-del', 'trash', 'Löschen', true)}
|
||||||
</div>` : '';
|
</div>` : '';
|
||||||
|
|
||||||
|
|
@ -2244,6 +2245,9 @@ window.Page_routes = (() => {
|
||||||
} catch (err) { UI.toast.error(err.message); }
|
} catch (err) { UI.toast.error(err.message); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Hunde bearbeiten
|
||||||
|
document.getElementById('rd-dogs')?.addEventListener('click', () => _openEditDogsModal(route));
|
||||||
|
|
||||||
// Löschen
|
// Löschen
|
||||||
document.getElementById('rd-del')?.addEventListener('click', async () => {
|
document.getElementById('rd-del')?.addEventListener('click', async () => {
|
||||||
const ok = await UI.modal.confirm({
|
const ok = await UI.modal.confirm({
|
||||||
|
|
@ -2304,6 +2308,70 @@ window.Page_routes = (() => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// Hunde einer Route bearbeiten
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
function _openEditDogsModal(route) {
|
||||||
|
const dogs = _appState?.dogs || [];
|
||||||
|
if (!dogs.length) { UI.toast.info('Keine Hunde im Profil vorhanden.'); return; }
|
||||||
|
|
||||||
|
const currentIds = new Set(route.dog_ids || []);
|
||||||
|
|
||||||
|
const dogRows = dogs.map(d => {
|
||||||
|
const checked = currentIds.has(d.id);
|
||||||
|
const av = d.foto_url
|
||||||
|
? `<img src="${UI.escape(d.foto_url)}" style="width:20px;height:20px;border-radius:50%;object-fit:cover;flex-shrink:0">`
|
||||||
|
: `<svg class="ph-icon" style="width:14px;height:14px;flex-shrink:0" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>`;
|
||||||
|
return `<label style="display:inline-flex;align-items:center;gap:6px;padding:5px 10px;
|
||||||
|
border:1.5px solid ${checked ? 'var(--c-primary)' : 'var(--c-border)'};
|
||||||
|
border-radius:100px;cursor:pointer;
|
||||||
|
background:${checked ? 'var(--c-primary-subtle)' : ''};
|
||||||
|
color:${checked ? 'var(--c-primary)' : ''};
|
||||||
|
font-size:var(--text-xs);font-weight:600;user-select:none">
|
||||||
|
<input type="checkbox" name="dog_ids" value="${d.id}" ${checked ? 'checked' : ''}
|
||||||
|
style="display:none" class="rd-dog-cb">
|
||||||
|
${av}<span>${UI.escape(d.name)}</span>
|
||||||
|
</label>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const body = `
|
||||||
|
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
|
||||||
|
Welche Hunde waren bei dieser Route dabei?
|
||||||
|
</p>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)" id="rd-dogs-picker">
|
||||||
|
${dogRows}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const footer = `
|
||||||
|
<button type="button" class="btn btn-secondary flex-1" id="rd-dogs-cancel">Abbrechen</button>
|
||||||
|
<button type="button" class="btn btn-primary flex-1" id="rd-dogs-save">${UI.icon('floppy-disk')} Speichern</button>
|
||||||
|
`;
|
||||||
|
UI.modal.open({ title: `${UI.icon('dog')} Hunde bearbeiten`, body, footer });
|
||||||
|
|
||||||
|
// Checkbox-Pill Styling
|
||||||
|
document.querySelectorAll('.rd-dog-cb').forEach(cb => {
|
||||||
|
const label = cb.closest('label');
|
||||||
|
cb.addEventListener('change', () => {
|
||||||
|
label.style.borderColor = cb.checked ? 'var(--c-primary)' : 'var(--c-border)';
|
||||||
|
label.style.background = cb.checked ? 'var(--c-primary-subtle)' : '';
|
||||||
|
label.style.color = cb.checked ? 'var(--c-primary)' : '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('rd-dogs-cancel')?.addEventListener('click', UI.modal.close);
|
||||||
|
|
||||||
|
document.getElementById('rd-dogs-save')?.addEventListener('click', async () => {
|
||||||
|
const btn = document.getElementById('rd-dogs-save');
|
||||||
|
await UI.asyncButton(btn, async () => {
|
||||||
|
const dogIds = [...document.querySelectorAll('.rd-dog-cb:checked')].map(c => parseInt(c.value));
|
||||||
|
await API.routes.updateDogs(route.id, dogIds);
|
||||||
|
route.dog_ids = dogIds;
|
||||||
|
UI.modal.close();
|
||||||
|
UI.toast.success('Hunde aktualisiert.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Richtungspfeile gleichmäßig entlang des Tracks platzieren
|
// Richtungspfeile gleichmäßig entlang des Tracks platzieren
|
||||||
function _addRouteArrows(map, track, color = '#fff') {
|
function _addRouteArrows(map, track, color = '#fff') {
|
||||||
if (track.length < 2) return;
|
if (track.length < 2) return;
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,8 @@ window.Page_trainingsplaene = (() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function _lsKey(planId, goalIdx) {
|
function _lsKey(planId, goalIdx) {
|
||||||
return `tp_${planId}_${goalIdx}`;
|
const dogId = _dogId() || 'x';
|
||||||
|
return `tp_d${dogId}_${planId}_${goalIdx}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _saveGoal(key, checked) {
|
function _saveGoal(key, checked) {
|
||||||
|
|
@ -537,6 +538,8 @@ window.Page_trainingsplaene = (() => {
|
||||||
// BIND EVENTS
|
// BIND EVENTS
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
function _bindEvents() {
|
function _bindEvents() {
|
||||||
|
UI.bindDogChip(_container, _appState);
|
||||||
|
|
||||||
// Notiz-Button
|
// Notiz-Button
|
||||||
const dogId = _dogId();
|
const dogId = _dogId();
|
||||||
_container.querySelector('#tp-note-btn')?.addEventListener('click', e => {
|
_container.querySelector('#tp-note-btn')?.addEventListener('click', e => {
|
||||||
|
|
@ -612,8 +615,9 @@ window.Page_trainingsplaene = (() => {
|
||||||
: `Erwachsener Hund – ${_activeAdultTab}`;
|
: `Erwachsener Hund – ${_activeAdultTab}`;
|
||||||
|
|
||||||
_container.innerHTML = `
|
_container.innerHTML = `
|
||||||
<div style="padding-bottom:var(--space-8)">
|
<div style="padding:var(--space-4) var(--space-4) var(--space-8)">
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin:var(--space-4) 0 var(--space-4)">
|
${UI.dogChip(_appState)}
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-4)">
|
||||||
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0">
|
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0">
|
||||||
${_icon('clipboard-text')} Trainingspläne
|
${_icon('clipboard-text')} Trainingspläne
|
||||||
</h2>
|
</h2>
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ window.Page_uebungen = (() => {
|
||||||
|
|
||||||
// In-memory cache (loaded from API on init)
|
// In-memory cache (loaded from API on init)
|
||||||
let _progressCache = {}; // key → statusId
|
let _progressCache = {}; // key → statusId
|
||||||
|
let _progressLoaded = false;
|
||||||
let _exerciseStats = {}; // exercise_id → {recent_avg, session_count, trend}
|
let _exerciseStats = {}; // exercise_id → {recent_avg, session_count, trend}
|
||||||
|
|
||||||
function _progressKey(tab, name) {
|
function _progressKey(tab, name) {
|
||||||
|
|
@ -83,17 +84,13 @@ window.Page_uebungen = (() => {
|
||||||
|
|
||||||
function _getStatus(tab, name) {
|
function _getStatus(tab, name) {
|
||||||
const k = _progressKey(tab, name);
|
const k = _progressKey(tab, name);
|
||||||
// Fallback to localStorage while API loads
|
return _progressCache[k] ?? null;
|
||||||
return _progressCache[k] !== undefined
|
|
||||||
? _progressCache[k]
|
|
||||||
: localStorage.getItem(_statusKey(tab, name)) || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function _setStatus(tab, name, statusId) {
|
function _setStatus(tab, name, statusId) {
|
||||||
const k = _progressKey(tab, name);
|
const k = _progressKey(tab, name);
|
||||||
_progressCache[k] = statusId;
|
_progressCache[k] = statusId;
|
||||||
localStorage.setItem(_statusKey(tab, name), statusId || ''); // keep localStorage in sync
|
API.training.setProgress(k, statusId, _dogId()).catch(() => {});
|
||||||
API.training.setProgress(k, statusId).catch(() => {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function _nextStatus(currentId) {
|
function _nextStatus(currentId) {
|
||||||
|
|
@ -504,28 +501,19 @@ window.Page_uebungen = (() => {
|
||||||
_scrollTarget = { exercise_id: params.exercise_id || '', name: params.name || '' };
|
_scrollTarget = { exercise_id: params.exercise_id || '', name: params.name || '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Progress vom Server laden
|
// Progress vom Server laden (hund-spezifisch)
|
||||||
API.training.getProgress().then(rows => {
|
const _did = _dogId();
|
||||||
rows.forEach(r => { _progressCache[r.exercise_id] = r.status; });
|
_progressLoaded = false;
|
||||||
// localStorage-Daten migrieren falls noch nicht im Backend
|
API.training.getProgress(_did)
|
||||||
Object.keys(localStorage).filter(k => k.startsWith('ub_status_')).forEach(lsKey => {
|
.then(rows => {
|
||||||
const parts = lsKey.replace('ub_status_', '').split('_');
|
_progressCache = {};
|
||||||
const tab = parts[0];
|
rows.forEach(r => { _progressCache[r.exercise_id] = r.status; });
|
||||||
const name = parts.slice(1).join('_');
|
_progressLoaded = true;
|
||||||
const apiKey = `${tab}_${name}`;
|
_renderContent();
|
||||||
if (_progressCache[apiKey] === undefined) {
|
}).catch(() => { _progressLoaded = true; _renderContent(); });
|
||||||
const val = localStorage.getItem(lsKey);
|
|
||||||
if (val) {
|
|
||||||
_progressCache[apiKey] = val;
|
|
||||||
API.training.setProgress(apiKey, val).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
_renderContent(); // Re-render with loaded progress
|
|
||||||
}).catch(() => {});
|
|
||||||
|
|
||||||
// Empfehlungen laden
|
// Empfehlungen laden
|
||||||
API.training.getSuggestions().then(suggestions => {
|
API.training.getSuggestions(_did).then(suggestions => {
|
||||||
if (suggestions.length) _showSuggestions(suggestions);
|
if (suggestions.length) _showSuggestions(suggestions);
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
|
|
@ -556,6 +544,7 @@ window.Page_uebungen = (() => {
|
||||||
_statsData = null;
|
_statsData = null;
|
||||||
_badgesData = null;
|
_badgesData = null;
|
||||||
_progressCache = {};
|
_progressCache = {};
|
||||||
|
_progressLoaded = false;
|
||||||
_exerciseStats = {};
|
_exerciseStats = {};
|
||||||
_render();
|
_render();
|
||||||
_loadStatsAndBadges();
|
_loadStatsAndBadges();
|
||||||
|
|
@ -568,6 +557,7 @@ window.Page_uebungen = (() => {
|
||||||
function _render() {
|
function _render() {
|
||||||
_container.innerHTML = `
|
_container.innerHTML = `
|
||||||
<div id="ueb-wrap">
|
<div id="ueb-wrap">
|
||||||
|
<div style="padding:var(--space-3) var(--space-4) 0">${UI.dogChip(_appState)}</div>
|
||||||
<div style="padding:var(--space-3) var(--space-4) var(--space-2)">
|
<div style="padding:var(--space-3) var(--space-4) var(--space-2)">
|
||||||
<table style="width:100%;border-collapse:collapse">
|
<table style="width:100%;border-collapse:collapse">
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -604,6 +594,7 @@ window.Page_uebungen = (() => {
|
||||||
<div id="ueb-content"></div>
|
<div id="ueb-content"></div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
UI.bindDogChip(_container, _appState);
|
||||||
_container.querySelector('#ueb-quicksetup-btn').addEventListener('click', _openQuickSetupModal);
|
_container.querySelector('#ueb-quicksetup-btn').addEventListener('click', _openQuickSetupModal);
|
||||||
_container.querySelector('#ueb-tabs')?.style.setProperty('--ueb-tab-cols', Math.ceil(TABS.length / 2));
|
_container.querySelector('#ueb-tabs')?.style.setProperty('--ueb-tab-cols', Math.ceil(TABS.length / 2));
|
||||||
_container.querySelector('#ueb-search')?.addEventListener('input', e => {
|
_container.querySelector('#ueb-search')?.addEventListener('input', e => {
|
||||||
|
|
@ -613,7 +604,12 @@ window.Page_uebungen = (() => {
|
||||||
_renderContent();
|
_renderContent();
|
||||||
});
|
});
|
||||||
_bindTabs();
|
_bindTabs();
|
||||||
_renderContent();
|
if (_progressLoaded) {
|
||||||
|
_renderContent();
|
||||||
|
} else {
|
||||||
|
const el = _container.querySelector('#ueb-content');
|
||||||
|
if (el) el.innerHTML = `<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted)"><svg class="ph-icon" style="width:24px;height:24px;animation:spin 1s linear infinite" aria-hidden="true"><use href="/icons/phosphor.svg#spinner"></use></svg></div>`;
|
||||||
|
}
|
||||||
_renderStatsBanner();
|
_renderStatsBanner();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -782,7 +778,18 @@ window.Page_uebungen = (() => {
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// SCHNELL-SETUP: Stand aller Übungen erfassen
|
// SCHNELL-SETUP: Stand aller Übungen erfassen
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
function _openQuickSetupModal() {
|
async function _openQuickSetupModal() {
|
||||||
|
// Sicherstellen dass Progress geladen ist bevor das Modal öffnet
|
||||||
|
if (!_progressLoaded) {
|
||||||
|
const did = _dogId();
|
||||||
|
try {
|
||||||
|
const rows = await API.training.getProgress(did);
|
||||||
|
_progressCache = {};
|
||||||
|
rows.forEach(r => { _progressCache[r.exercise_id] = r.status; });
|
||||||
|
_progressLoaded = true;
|
||||||
|
_renderContent();
|
||||||
|
} catch { _progressLoaded = true; }
|
||||||
|
}
|
||||||
const ALL = [
|
const ALL = [
|
||||||
{ group: 'Grundkommandos', tab: 'grundkommandos', items: GRUNDKOMMANDOS },
|
{ group: 'Grundkommandos', tab: 'grundkommandos', items: GRUNDKOMMANDOS },
|
||||||
{ group: 'Tricks', tab: 'tricks', items: TRICKS },
|
{ group: 'Tricks', tab: 'tricks', items: TRICKS },
|
||||||
|
|
@ -883,11 +890,8 @@ window.Page_uebungen = (() => {
|
||||||
|
|
||||||
// Alle geänderten Status speichern
|
// Alle geänderten Status speichern
|
||||||
const parts = Object.entries(pending).map(([key, val]) => {
|
const parts = Object.entries(pending).map(([key, val]) => {
|
||||||
const [tab, ...rest] = key.split('_');
|
|
||||||
const name = rest.join('_').replace(/_/g, ' ');
|
|
||||||
_progressCache[key] = val || null;
|
_progressCache[key] = val || null;
|
||||||
localStorage.setItem(`ub_status_${key}`, val || '');
|
return API.training.setProgress(key, val || null, _dogId());
|
||||||
return API.training.setProgress(key, val || null);
|
|
||||||
});
|
});
|
||||||
await Promise.allSettled(parts);
|
await Promise.allSettled(parts);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -397,7 +397,9 @@ window.Page_wetter = (() => {
|
||||||
: 0;
|
: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const locName = _data.location_name ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">${_esc(_data.location_name)}</div>` : '';
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
|
${locName}
|
||||||
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4)">
|
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4)">
|
||||||
${_wmoIcon(d.weathercode, '3.5rem')}
|
${_wmoIcon(d.weathercode, '3.5rem')}
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -995,6 +995,30 @@ const UI = (() => {
|
||||||
_load();
|
_load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dogChip(appState) {
|
||||||
|
const dog = appState?.activeDog;
|
||||||
|
const dogs = appState?.dogs || [];
|
||||||
|
if (!dog) return '';
|
||||||
|
const av = dog.foto_url
|
||||||
|
? `<img src="${escape(dog.foto_url)}" style="width:22px;height:22px;border-radius:50%;object-fit:cover;flex-shrink:0">`
|
||||||
|
: `<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary);flex-shrink:0"><use href="/icons/phosphor.svg#dog"></use></svg>`;
|
||||||
|
const sw = dogs.length > 1
|
||||||
|
? `<svg class="ph-icon" style="width:13px;height:13px;color:var(--c-text-muted);margin-left:2px"><use href="/icons/phosphor.svg#arrows-left-right"></use></svg>` : '';
|
||||||
|
return `<div class="by-dog-chip" data-dog-chip style="display:inline-flex;align-items:center;gap:6px;
|
||||||
|
padding:4px 10px 4px 6px;background:var(--c-surface-2);border:1px solid var(--c-border);
|
||||||
|
border-radius:100px;font-size:var(--text-xs);font-weight:600;color:var(--c-text);
|
||||||
|
${dogs.length > 1 ? 'cursor:pointer' : ''};max-width:fit-content">${av}<span>${escape(dog.name)}</span>${sw}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindDogChip(container, appState) {
|
||||||
|
if ((appState?.dogs?.length || 0) < 2) return;
|
||||||
|
container.querySelector('[data-dog-chip]')?.addEventListener('click', () => {
|
||||||
|
const dogs = appState.dogs;
|
||||||
|
const next = dogs.find(d => d.id !== appState.activeDog?.id) || dogs[0];
|
||||||
|
if (next) App.setActiveDog(next.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Öffentliche API
|
// Öffentliche API
|
||||||
return {
|
return {
|
||||||
toast, modal,
|
toast, modal,
|
||||||
|
|
@ -1009,6 +1033,10 @@ const UI = (() => {
|
||||||
leafletMarker,
|
leafletMarker,
|
||||||
locationPicker,
|
locationPicker,
|
||||||
ratingStars,
|
ratingStars,
|
||||||
|
dogChip,
|
||||||
|
bindDogChip,
|
||||||
|
dogChip,
|
||||||
|
bindDogChip,
|
||||||
};
|
};
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -1114,6 +1114,7 @@ window.Worlds = (() => {
|
||||||
${gassiScore ? `<span style="font-size:var(--text-xs);color:rgba(255,255,255,0.4);font-weight:600">/10</span>` : ''}
|
${gassiScore ? `<span style="font-size:var(--text-xs);color:rgba(255,255,255,0.4);font-weight:600">/10</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
${w ? `<div style="font-size:9px;font-weight:500;margin-top:2px;line-height:1.5">
|
${w ? `<div style="font-size:9px;font-weight:500;margin-top:2px;line-height:1.5">
|
||||||
|
${w.location_name ? `<div style="color:rgba(255,255,255,0.5)">${w.location_name}</div>` : ''}
|
||||||
<div style="color:rgba(255,255,255,0.75)">${Math.round(w.temp_c ?? 0)}° · ${w.precip_prob ?? 0}% Regen</div>
|
<div style="color:rgba(255,255,255,0.75)">${Math.round(w.temp_c ?? 0)}° · ${w.precip_prob ?? 0}% Regen</div>
|
||||||
${w.rain_warning_time ? `<div style="color:#fbbf24;font-weight:700">⚠ Umschwung ab ${w.rain_warning_time}</div>` : w.next_rain_time ? `<div style="color:rgba(255,255,255,0.5)">ab ${w.next_rain_time} Uhr</div>` : ''}
|
${w.rain_warning_time ? `<div style="color:#fbbf24;font-weight:700">⚠ Umschwung ab ${w.rain_warning_time}</div>` : w.next_rain_time ? `<div style="color:rgba(255,255,255,0.5)">ab ${w.next_rain_time} Uhr</div>` : ''}
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ BAN YARO — Wetter via Open-Meteo
|
||||||
- get_weather_for_location(): API-Endpoint, beliebiger Standort mit TTL-Cache
|
- get_weather_for_location(): API-Endpoint, beliebiger Standort mit TTL-Cache
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -62,9 +63,28 @@ async def get_weather_for_location(lat: float, lon: float) -> dict:
|
||||||
"&timezone=Europe%2FBerlin&forecast_days=1"
|
"&timezone=Europe%2FBerlin&forecast_days=1"
|
||||||
)
|
)
|
||||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||||
resp = await client.get(url)
|
resp, geo_resp = await asyncio.gather(
|
||||||
resp.raise_for_status()
|
client.get(url),
|
||||||
raw = resp.json()
|
client.get(
|
||||||
|
f"https://nominatim.openstreetmap.org/reverse?lat={lat}&lon={lon}&format=json&zoom=10",
|
||||||
|
headers={"User-Agent": "BanYaro/1.0 support@banyaro.app"}
|
||||||
|
),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
raw = resp.json()
|
||||||
|
|
||||||
|
# Ortsname aus Reverse-Geocoding
|
||||||
|
location_name = None
|
||||||
|
try:
|
||||||
|
if not isinstance(geo_resp, Exception) and geo_resp.status_code == 200:
|
||||||
|
geo = geo_resp.json()
|
||||||
|
addr = geo.get("address", {})
|
||||||
|
location_name = (addr.get("city") or addr.get("town") or
|
||||||
|
addr.get("village") or addr.get("municipality") or
|
||||||
|
addr.get("county") or geo.get("name"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
cur = raw.get('current', {})
|
cur = raw.get('current', {})
|
||||||
daily = raw.get('daily', {})
|
daily = raw.get('daily', {})
|
||||||
|
|
@ -130,9 +150,10 @@ async def get_weather_for_location(lat: float, lon: float) -> dict:
|
||||||
'precip_prob': precip,
|
'precip_prob': precip,
|
||||||
'uv_index': uv,
|
'uv_index': uv,
|
||||||
'is_day': bool(is_day),
|
'is_day': bool(is_day),
|
||||||
'zecken_warnung': zecken,
|
'zecken_warnung': zecken,
|
||||||
'next_rain_time': next_rain_time,
|
'next_rain_time': next_rain_time,
|
||||||
'rain_warning_time': rain_warning_time,
|
'rain_warning_time': rain_warning_time,
|
||||||
|
'location_name': location_name,
|
||||||
}
|
}
|
||||||
_location_cache[key] = (now, data)
|
_location_cache[key] = (now, data)
|
||||||
return data
|
return data
|
||||||
|
|
@ -272,9 +293,23 @@ async def get_forecast(lat: float, lon: float) -> dict:
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
forecast_task = client.get(forecast_url)
|
forecast_task = client.get(forecast_url)
|
||||||
pollen_task = client.get(pollen_url)
|
pollen_task = client.get(pollen_url)
|
||||||
forecast_resp, pollen_resp = await asyncio.gather(
|
geo_task = client.get(
|
||||||
forecast_task, pollen_task, return_exceptions=True
|
f"https://nominatim.openstreetmap.org/reverse?lat={lat}&lon={lon}&format=json&zoom=10",
|
||||||
|
headers={"User-Agent": "BanYaro/1.0 support@banyaro.app"}
|
||||||
)
|
)
|
||||||
|
forecast_resp, pollen_resp, geo_resp_fc = await asyncio.gather(
|
||||||
|
forecast_task, pollen_task, geo_task, return_exceptions=True
|
||||||
|
)
|
||||||
|
|
||||||
|
location_name_fc = None
|
||||||
|
try:
|
||||||
|
if not isinstance(geo_resp_fc, Exception) and geo_resp_fc.status_code == 200:
|
||||||
|
addr = geo_resp_fc.json().get("address", {})
|
||||||
|
location_name_fc = (addr.get("city") or addr.get("town") or
|
||||||
|
addr.get("village") or addr.get("municipality") or
|
||||||
|
addr.get("county"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# --- Forecast (required) ---
|
# --- Forecast (required) ---
|
||||||
if isinstance(forecast_resp, Exception):
|
if isinstance(forecast_resp, Exception):
|
||||||
|
|
@ -421,7 +456,7 @@ async def get_forecast(lat: float, lon: float) -> dict:
|
||||||
'hourly': _hourly_by_day.get(date_str, []),
|
'hourly': _hourly_by_day.get(date_str, []),
|
||||||
})
|
})
|
||||||
|
|
||||||
result = {'timezone': timezone, 'days': days}
|
result = {'timezone': timezone, 'days': days, 'location_name': location_name_fc}
|
||||||
_forecast_cache[key] = (now, result)
|
_forecast_cache[key] = (now, result)
|
||||||
_log_forecast(round(lat, 1), round(lon, 1), days)
|
_log_forecast(round(lat, 1), round(lon, 1), days)
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue