From aa4849d94739d52dd17333c8f542dc1a768631ee Mon Sep 17 00:00:00 2001 From: rene Date: Mon, 4 May 2026 21:09:35 +0200 Subject: [PATCH] =?UTF-8?q?Feature:=203=20Community-Features=20=E2=80=94?= =?UTF-8?q?=20Foto-Challenge,=20Stamm-Gassis,=20Rassen-Chip=20(SW=20by-v70?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Foto-Challenge der Woche: DB-Tabellen, routes/challenges.py (current/submit/vote/winners), Scheduler-Job jeden Montag 08:00, walks.js Challenge-Tab mit Banner, Galerie, Voting-Herz - Gassi-Zeiten-Pool: DB-Tabelle gassi_zeiten, routes/gassi_zeiten.py (CRUD + Umkreis), walks.js Stamm-Gassis-Tab mit Karten, Wochentag-Selector, Mitmachen→Chat - Rassen-Treffen-Chip: GET /api/friends/same-breed, dog-profile.js zeigt Chip wenn andere User gleiche Rasse haben, Klick → Forum mit Rassen-Suche vorausgefüllt --- backend/main.py | 4 + backend/routes/challenges.py | 304 ++++++++++++++++ backend/routes/friends.py | 53 +++ backend/routes/gassi_zeiten.py | 190 ++++++++++ backend/scheduler.py | 50 ++- backend/static/css/components.css | 230 +++++++++++++ backend/static/js/pages/dog-profile.js | 36 +- backend/static/js/pages/forum.js | 11 +- backend/static/js/pages/walks.js | 460 ++++++++++++++++++++++++- backend/static/sw.js | 6 +- 10 files changed, 1322 insertions(+), 22 deletions(-) create mode 100644 backend/routes/challenges.py create mode 100644 backend/routes/gassi_zeiten.py diff --git a/backend/main.py b/backend/main.py index 1d23aef..bed79a5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -233,6 +233,8 @@ from routes.health_docs import router as health_docs_router from routes.passport import router as passport_router from routes.playdate import router as playdate_router from routes.ernaehrung import router as ernaehrung_router +from routes.challenges import router as challenges_router +from routes.gassi_zeiten import router as gassi_zeiten_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -292,6 +294,8 @@ app.include_router(health_docs_router, prefix="/api/health-docs", t app.include_router(passport_router, prefix="/api/passport", tags=["Hundepass"]) app.include_router(playdate_router, prefix="/api/playdate", tags=["Playdate"]) app.include_router(ernaehrung_router, prefix="/api/dogs", tags=["Ernährung"]) +app.include_router(challenges_router, prefix="/api/challenges", tags=["Foto-Challenge"]) +app.include_router(gassi_zeiten_router, prefix="/api/gassi-zeiten", tags=["Gassi-Zeiten"]) # ------------------------------------------------------------------ diff --git a/backend/routes/challenges.py b/backend/routes/challenges.py new file mode 100644 index 0000000..f3e3f0d --- /dev/null +++ b/backend/routes/challenges.py @@ -0,0 +1,304 @@ +"""BAN YARO — Foto-Challenge der Woche""" + +import os +import uuid +import logging +from datetime import date, timedelta +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form +from pydantic import BaseModel +from typing import Optional +from database import db +from auth import get_current_user, get_current_user_optional +from media_utils import convert_media, generate_preview + +logger = logging.getLogger(__name__) + +router = APIRouter() + +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") +CHALLENGE_DIR = os.path.join(MEDIA_DIR, "challenges") + +_CHALLENGE_THEMEN = [ + "Bestes Schnüffel-Foto 👃", + "Action-Aufnahme 🏃", + "Schlafendes Tier 😴", + "Gassi im Regen 🌧️", + "Hundeblick in die Kamera 👀", + "Spielzeit mit Freunden 🐕", + "Herbstspaziergang 🍂", + "Beste Sprung-Aufnahme 🦘", + "Hund am Wasser 🌊", + "Erstes Mal im Schnee ❄️", + "Genuss-Moment 🦴", + "Versteckt im Gebüsch 🌿", + "Tierisches Selfie 🤳", + "Hund & Kind 👶", + "Hund & Katze zusammen 🐱", + "Der beste Buddel-Moment 🐾", + "Freude beim Apportieren 🎾", + "Hund in seiner Lieblingshöhle 🛋️", + "Sonnenuntergangs-Gassi 🌅", + "Hundebegegnung auf dem Spaziergang 🐕🐕", + "Ausdrucksstarker Hundeblick 😍", + "Hund im Herbstlaub 🍁", + "Welpenfoto 🍼", + "Seniorenhund im Porträt 👴", + "Lustigste Schlafposition 💤", + "Hund trägt etwas 🎀", + "Hund + Besitzer Spiegelfoto 🪞", + "Hund auf Abenteuer 🏕️", + "Beste Lauf-Action 💨", + "Hund im Café ☕", +] + + +def _current_week_monday() -> str: + today = date.today() + monday = today - timedelta(days=today.weekday()) + return monday.isoformat() + + +def _current_week_sunday() -> str: + monday = date.fromisoformat(_current_week_monday()) + return (monday + timedelta(days=6)).isoformat() + + +def _ensure_current_challenge(conn) -> int: + """Stellt sicher dass eine Challenge für die aktuelle Woche existiert. Gibt die ID zurück.""" + monday = _current_week_monday() + sunday = _current_week_sunday() + + existing = conn.execute( + "SELECT id FROM foto_challenge WHERE start_date = ?", (monday,) + ).fetchone() + if existing: + return existing["id"] + + # Thema aus Rotation wählen (Wochennummer % Anzahl Themen) + week_num = date.today().isocalendar()[1] + thema = _CHALLENGE_THEMEN[week_num % len(_CHALLENGE_THEMEN)] + + cur = conn.execute( + "INSERT INTO foto_challenge (thema, beschreibung, start_date, end_date, created_by) " + "VALUES (?, ?, ?, ?, NULL)", + (thema, f"Diese Woche: {thema}", monday, sunday) + ) + return cur.lastrowid + + +# ------------------------------------------------------------------ +# GET /api/challenges/current +# ------------------------------------------------------------------ +@router.get("/current") +async def get_current_challenge(user=Depends(get_current_user_optional)): + with db() as conn: + challenge_id = _ensure_current_challenge(conn) + challenge = conn.execute( + "SELECT * FROM foto_challenge WHERE id = ?", (challenge_id,) + ).fetchone() + + submissions = conn.execute(""" + SELECT cs.id, cs.user_id, cs.dog_id, cs.foto_url, cs.caption, cs.votes, cs.created_at, + u.name AS user_name, u.avatar_url, + d.name AS dog_name, d.foto_url AS dog_foto_url + FROM challenge_submissions cs + LEFT JOIN users u ON u.id = cs.user_id + LEFT JOIN dogs d ON d.id = cs.dog_id + WHERE cs.challenge_id = ? + ORDER BY cs.votes DESC, cs.created_at ASC + """, (challenge_id,)).fetchall() + + my_submission = None + my_votes = set() + if user: + mine = conn.execute( + "SELECT id FROM challenge_submissions WHERE challenge_id=? AND user_id=?", + (challenge_id, user["id"]) + ).fetchone() + if mine: + my_submission = mine["id"] + voted_rows = conn.execute( + "SELECT cv.submission_id FROM challenge_votes cv " + "JOIN challenge_submissions cs ON cs.id = cv.submission_id " + "WHERE cv.user_id = ? AND cs.challenge_id = ?", + (user["id"], challenge_id) + ).fetchall() + my_votes = {r["submission_id"] for r in voted_rows} + + # Countdown bis Sonntag + end = date.fromisoformat(challenge["end_date"]) + days_left = (end - date.today()).days + 1 + + result_subs = [] + for s in submissions: + sd = dict(s) + sd["i_voted"] = (sd["id"] in my_votes) if user else False + result_subs.append(sd) + + return { + "challenge": dict(challenge), + "submissions": result_subs, + "my_submission_id": my_submission, + "days_left": max(0, days_left), + } + + +# ------------------------------------------------------------------ +# POST /api/challenges/{id}/submit +# ------------------------------------------------------------------ +@router.post("/{challenge_id}/submit", status_code=201) +async def submit_photo( + challenge_id: int, + caption: Optional[str] = Form(None), + dog_id: Optional[int] = Form(None), + foto: UploadFile = File(...), + user=Depends(get_current_user), +): + with db() as conn: + challenge = conn.execute( + "SELECT * FROM foto_challenge WHERE id = ?", (challenge_id,) + ).fetchone() + if not challenge: + raise HTTPException(404, "Challenge nicht gefunden.") + + today = date.today().isoformat() + if today > challenge["end_date"]: + raise HTTPException(400, "Die Challenge ist bereits beendet.") + + # Doppelt-Check + existing = conn.execute( + "SELECT id FROM challenge_submissions WHERE challenge_id=? AND user_id=?", + (challenge_id, user["id"]) + ).fetchone() + if existing: + raise HTTPException(409, "Du hast bereits ein Foto eingereicht.") + + # Foto speichern + os.makedirs(CHALLENGE_DIR, exist_ok=True) + orig_filename = foto.filename or "foto.jpg" + ext = os.path.splitext(orig_filename)[1] or ".jpg" + base = uuid.uuid4().hex + + raw = await foto.read() + + # HEIC→JPEG Konvertierung falls nötig + try: + converted, out_ext = convert_media(raw, orig_filename) + except Exception: + converted, out_ext = raw, ext + + save_filename = f"{base}{out_ext}" + save_path = os.path.join(CHALLENGE_DIR, save_filename) + with open(save_path, "wb") as f: + f.write(converted) + foto_url = f"/media/challenges/{save_filename}" + + # Preview + try: + preview = generate_preview(converted, out_ext) + if preview: + prev_path = os.path.join(CHALLENGE_DIR, f"{base}_preview.webp") + with open(prev_path, "wb") as f: + f.write(preview) + except Exception: + pass + + with db() as conn: + cur = conn.execute( + "INSERT INTO challenge_submissions (challenge_id, user_id, dog_id, foto_url, caption) " + "VALUES (?, ?, ?, ?, ?)", + (challenge_id, user["id"], dog_id, foto_url, caption) + ) + row = conn.execute(""" + SELECT cs.*, u.name AS user_name, d.name AS dog_name + FROM challenge_submissions cs + LEFT JOIN users u ON u.id = cs.user_id + LEFT JOIN dogs d ON d.id = cs.dog_id + WHERE cs.id = ? + """, (cur.lastrowid,)).fetchone() + + return dict(row) + + +# ------------------------------------------------------------------ +# POST /api/challenges/submissions/{id}/vote — Toggle-Vote +# ------------------------------------------------------------------ +@router.post("/submissions/{submission_id}/vote") +async def vote_submission(submission_id: int, user=Depends(get_current_user)): + with db() as conn: + sub = conn.execute( + "SELECT * FROM challenge_submissions WHERE id = ?", (submission_id,) + ).fetchone() + if not sub: + raise HTTPException(404, "Einreichung nicht gefunden.") + if sub["user_id"] == user["id"]: + raise HTTPException(400, "Du kannst nicht für dein eigenes Foto abstimmen.") + + existing = conn.execute( + "SELECT id FROM challenge_votes WHERE submission_id=? AND user_id=?", + (submission_id, user["id"]) + ).fetchone() + + if existing: + # Toggle: Vote entfernen + conn.execute( + "DELETE FROM challenge_votes WHERE submission_id=? AND user_id=?", + (submission_id, user["id"]) + ) + conn.execute( + "UPDATE challenge_submissions SET votes = MAX(0, votes - 1) WHERE id=?", + (submission_id,) + ) + voted = False + else: + conn.execute( + "INSERT INTO challenge_votes (submission_id, user_id) VALUES (?, ?)", + (submission_id, user["id"]) + ) + conn.execute( + "UPDATE challenge_submissions SET votes = votes + 1 WHERE id=?", + (submission_id,) + ) + voted = True + + votes = conn.execute( + "SELECT votes FROM challenge_submissions WHERE id=?", (submission_id,) + ).fetchone()["votes"] + + return {"voted": voted, "votes": votes} + + +# ------------------------------------------------------------------ +# GET /api/challenges/winners — letzte 4 Gewinner +# ------------------------------------------------------------------ +@router.get("/winners") +async def get_winners(): + with db() as conn: + # Vergangene Challenges (ohne aktuelle Woche) + monday = _current_week_monday() + challenges = conn.execute( + "SELECT id, thema, start_date, end_date FROM foto_challenge " + "WHERE end_date < ? ORDER BY end_date DESC LIMIT 4", + (monday,) + ).fetchall() + + winners = [] + for ch in challenges: + winner = conn.execute(""" + SELECT cs.id, cs.user_id, cs.foto_url, cs.caption, cs.votes, + u.name AS user_name, u.avatar_url, + d.name AS dog_name + FROM challenge_submissions cs + LEFT JOIN users u ON u.id = cs.user_id + LEFT JOIN dogs d ON d.id = cs.dog_id + WHERE cs.challenge_id = ? + ORDER BY cs.votes DESC, cs.created_at ASC + LIMIT 1 + """, (ch["id"],)).fetchone() + + winners.append({ + "challenge": dict(ch), + "winner": dict(winner) if winner else None, + }) + + return winners diff --git a/backend/routes/friends.py b/backend/routes/friends.py index cac5f4b..7df14e4 100644 --- a/backend/routes/friends.py +++ b/backend/routes/friends.py @@ -342,3 +342,56 @@ async def remove_friend(friend_user_id: int, user=Depends(get_current_user)): AND ((requester_id=? AND addressee_id=?) OR (requester_id=? AND addressee_id=?)) """, (uid, friend_user_id, friend_user_id, uid)) return {"ok": True} + + +# ------------------------------------------------------------------ +# GET /api/friends/same-breed — andere User mit gleicher Rasse +# ------------------------------------------------------------------ +@router.get("/same-breed") +async def same_breed(user=Depends(get_current_user)): + """Findet andere User mit Hunden derselben Rasse. Gibt Anzahl + Forum-Suche zurück.""" + uid = user["id"] + with db() as conn: + # Rassen des eingeloggten Users + my_dogs = conn.execute( + "SELECT rasse FROM dogs WHERE user_id=? AND rasse IS NOT NULL AND rasse != ''", + (uid,) + ).fetchall() + + if not my_dogs: + return {"count": 0, "rassen": [], "forum_query": None} + + rassen = list({d["rasse"].strip() for d in my_dogs if d["rasse"]}) + + # Andere User (nicht ich) die eine dieser Rassen haben + ph = ",".join("?" * len(rassen)) + count_row = conn.execute(f""" + SELECT COUNT(DISTINCT d.user_id) AS cnt + FROM dogs d + WHERE d.user_id != ? + AND d.rasse IN ({ph}) + """, (uid, *rassen)).fetchone() + + count = count_row["cnt"] if count_row else 0 + + # Für jede Rasse: wie viele andere User + rassen_detail = [] + for rasse in rassen: + n = conn.execute( + "SELECT COUNT(DISTINCT user_id) AS cnt FROM dogs " + "WHERE user_id != ? AND rasse = ?", + (uid, rasse) + ).fetchone()["cnt"] + if n > 0: + rassen_detail.append({"rasse": rasse, "count": n}) + + rassen_detail.sort(key=lambda x: -x["count"]) + + # Forum-Suche-Link für die häufigste Rasse + forum_query = rassen_detail[0]["rasse"] if rassen_detail else None + + return { + "count": count, + "rassen": rassen_detail, + "forum_query": forum_query, + } diff --git a/backend/routes/gassi_zeiten.py b/backend/routes/gassi_zeiten.py new file mode 100644 index 0000000..77ff52f --- /dev/null +++ b/backend/routes/gassi_zeiten.py @@ -0,0 +1,190 @@ +"""BAN YARO — Gassi-Zeiten-Pool (regelmäßige Gassi-Zeiten mit Gleichgesinnten)""" + +import json +import math +import logging +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import Optional, List +from database import db +from auth import get_current_user + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +def _haversine(lat1, lon1, lat2, lon2): + R = 6_371_000 + p1, p2 = math.radians(lat1), math.radians(lat2) + dp = math.radians(lat2 - lat1) + dl = math.radians(lon2 - lon1) + a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 + return 2 * R * math.asin(math.sqrt(a)) + + +class GassiZeitCreate(BaseModel): + dog_id: Optional[int] = None + wochentage: List[str] # ["mo", "mi", "fr"] + uhrzeit: str # "17:00" + ort_name: Optional[str] = None + lat: Optional[float] = None + lon: Optional[float] = None + radius_m: int = 500 + notiz: Optional[str] = None + + +class GassiZeitUpdate(BaseModel): + aktiv: Optional[int] = None + + +# ------------------------------------------------------------------ +# GET /api/gassi-zeiten — alle in der Nähe (oder eigene) +# ------------------------------------------------------------------ +@router.get("") +async def list_gassi_zeiten( + lat: Optional[float] = None, + lon: Optional[float] = None, + radius: int = 5000, # Meter + nur_eigene: bool = False, + user=Depends(get_current_user), +): + with db() as conn: + if nur_eigene: + rows = conn.execute(""" + SELECT gz.*, u.name AS user_name, u.avatar_url, + d.name AS dog_name, d.foto_url AS dog_foto_url, d.rasse AS dog_rasse + FROM gassi_zeiten gz + LEFT JOIN users u ON u.id = gz.user_id + LEFT JOIN dogs d ON d.id = gz.dog_id + WHERE gz.user_id = ? + ORDER BY gz.uhrzeit ASC + """, (user["id"],)).fetchall() + else: + rows = conn.execute(""" + SELECT gz.*, u.name AS user_name, u.avatar_url, + d.name AS dog_name, d.foto_url AS dog_foto_url, d.rasse AS dog_rasse + FROM gassi_zeiten gz + LEFT JOIN users u ON u.id = gz.user_id + LEFT JOIN dogs d ON d.id = gz.dog_id + WHERE gz.aktiv = 1 + ORDER BY gz.uhrzeit ASC + """).fetchall() + + result = [] + for r in rows: + d = dict(r) + # wochentage JSON parsen + try: + d["wochentage"] = json.loads(d["wochentage"]) if isinstance(d["wochentage"], str) else d["wochentage"] + except Exception: + d["wochentage"] = [] + d["is_mine"] = (d["user_id"] == user["id"]) + + # Distanz-Filter + if lat is not None and lon is not None and d.get("lat") and d.get("lon"): + dist = _haversine(lat, lon, d["lat"], d["lon"]) + if not nur_eigene and dist > radius: + continue + d["distance_m"] = int(dist) + else: + d["distance_m"] = None + + result.append(d) + + # Sortierung: eigene zuerst, dann nach Distanz + result.sort(key=lambda x: (0 if x["is_mine"] else 1, x.get("distance_m") or 99999)) + return result + + +# ------------------------------------------------------------------ +# POST /api/gassi-zeiten — eigene Zeit anlegen +# ------------------------------------------------------------------ +@router.post("", status_code=201) +async def create_gassi_zeit(data: GassiZeitCreate, user=Depends(get_current_user)): + if not data.wochentage: + raise HTTPException(400, "Mindestens ein Wochentag muss angegeben werden.") + if not data.uhrzeit: + raise HTTPException(400, "Uhrzeit muss angegeben werden.") + + wochentage_json = json.dumps(data.wochentage) + + with db() as conn: + # Hund-Prüfung + if data.dog_id: + dog = conn.execute( + "SELECT id FROM dogs WHERE id=? AND user_id=?", (data.dog_id, user["id"]) + ).fetchone() + if not dog: + raise HTTPException(403, "Hund nicht gefunden oder gehört nicht dir.") + + cur = conn.execute(""" + INSERT INTO gassi_zeiten (user_id, dog_id, wochentage, uhrzeit, + ort_name, lat, lon, radius_m, notiz) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (user["id"], data.dog_id, wochentage_json, data.uhrzeit, + data.ort_name, data.lat, data.lon, data.radius_m, data.notiz)) + + row = conn.execute(""" + SELECT gz.*, u.name AS user_name, d.name AS dog_name, d.foto_url AS dog_foto_url + FROM gassi_zeiten gz + LEFT JOIN users u ON u.id = gz.user_id + LEFT JOIN dogs d ON d.id = gz.dog_id + WHERE gz.id = ? + """, (cur.lastrowid,)).fetchone() + + result = dict(row) + try: + result["wochentage"] = json.loads(result["wochentage"]) + except Exception: + pass + result["is_mine"] = True + return result + + +# ------------------------------------------------------------------ +# PATCH /api/gassi-zeiten/{id} — pausieren / aktivieren +# ------------------------------------------------------------------ +@router.patch("/{gz_id}") +async def update_gassi_zeit(gz_id: int, data: GassiZeitUpdate, user=Depends(get_current_user)): + with db() as conn: + gz = conn.execute( + "SELECT * FROM gassi_zeiten WHERE id=?", (gz_id,) + ).fetchone() + if not gz: + raise HTTPException(404, "Gassi-Zeit nicht gefunden.") + if gz["user_id"] != user["id"]: + raise HTTPException(403, "Nicht deine Gassi-Zeit.") + + updates = data.model_dump(exclude_none=True) + if updates: + cols = ", ".join(f"{k} = ?" for k in updates) + conn.execute(f"UPDATE gassi_zeiten SET {cols} WHERE id=?", [*updates.values(), gz_id]) + + row = conn.execute( + "SELECT * FROM gassi_zeiten WHERE id=?", (gz_id,) + ).fetchone() + + result = dict(row) + try: + result["wochentage"] = json.loads(result["wochentage"]) + except Exception: + pass + result["is_mine"] = True + return result + + +# ------------------------------------------------------------------ +# DELETE /api/gassi-zeiten/{id} +# ------------------------------------------------------------------ +@router.delete("/{gz_id}", status_code=204) +async def delete_gassi_zeit(gz_id: int, user=Depends(get_current_user)): + with db() as conn: + gz = conn.execute( + "SELECT * FROM gassi_zeiten WHERE id=?", (gz_id,) + ).fetchone() + if not gz: + raise HTTPException(404, "Gassi-Zeit nicht gefunden.") + if gz["user_id"] != user["id"]: + raise HTTPException(403, "Nicht deine Gassi-Zeit.") + conn.execute("DELETE FROM gassi_zeiten WHERE id=?", (gz_id,)) diff --git a/backend/scheduler.py b/backend/scheduler.py index 4d1dbff..22d1533 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -156,6 +156,14 @@ def start(): replace_existing=True, misfire_grace_time=3600, ) + # Jeden Montag 08:00 Uhr — Neue Foto-Challenge anlegen + _scheduler.add_job( + _job_new_foto_challenge, + CronTrigger(day_of_week='mon', hour=8, minute=0), + id="new_foto_challenge", + replace_existing=True, + misfire_grace_time=3600, + ) # Täglich 07:00 Uhr — Goldene Gassi-Stunde _scheduler.add_job( _job_golden_gassi_hour, @@ -181,7 +189,7 @@ def start(): misfire_grace_time=3600, ) _scheduler.start() - logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00, Goldene-Gassi-Stunde 07:00, Jahrestags-Erinnerungen 09:00, Monatlicher-Rückblick 1. des Monats 10:00. OSM-Cache: on-demand (kein Prewarm).") + logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00, Goldene-Gassi-Stunde 07:00, Jahrestags-Erinnerungen 09:00, Monatlicher-Rückblick 1. des Monats 10:00, Foto-Challenge Mo 08:00. OSM-Cache: on-demand (kein Prewarm).") def stop(): @@ -1544,6 +1552,46 @@ async def _job_monthly_recap(): _log_job("monthly_recap", "ok", f"{sent_total} Push für {month_label}") +async def _job_new_foto_challenge(): + """Jeden Montag 08:00 — neue Foto-Challenge für die aktuelle Woche anlegen.""" + from datetime import date, timedelta + from routes.challenges import _CHALLENGE_THEMEN, _current_week_monday, _current_week_sunday + + monday = _current_week_monday() + sunday = _current_week_sunday() + + week_num = date.today().isocalendar()[1] + thema = _CHALLENGE_THEMEN[week_num % len(_CHALLENGE_THEMEN)] + + with db() as conn: + existing = conn.execute( + "SELECT id FROM foto_challenge WHERE start_date = ?", (monday,) + ).fetchone() + if existing: + logger.info(f"Foto-Challenge: Woche {monday} bereits vorhanden (id={existing['id']}).") + _log_job("new_foto_challenge", "ok", f"Bereits vorhanden für {monday}") + return + + cur = conn.execute( + "INSERT INTO foto_challenge (thema, beschreibung, start_date, end_date, created_by) " + "VALUES (?, ?, ?, ?, NULL)", + (thema, f"Diese Woche: {thema}", monday, sunday) + ) + challenge_id = cur.lastrowid + + # Push an alle User + send_push_to_all({ + "type": "foto_challenge", + "title": "📸 Neue Foto-Challenge!", + "body": f"Diese Woche: {thema} — mach mit!", + "data": {"page": "walks", "tab": "challenge"}, + "tag": f"challenge-{monday}", + }) + + logger.info(f"Foto-Challenge angelegt: '{thema}' für {monday}–{sunday} (id={challenge_id}).") + _log_job("new_foto_challenge", "ok", f"'{thema}' für {monday}") + + async def _fetch_hourly_weather(lat: float, lon: float) -> list[dict]: """Holt stündliche Wetterdaten für heute von Open-Meteo.""" import httpx diff --git a/backend/static/css/components.css b/backend/static/css/components.css index c8de6a6..4b46952 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -7304,6 +7304,20 @@ svg.empty-state-icon { color: var(--c-text-secondary); line-height: 1.2; } +.exp-kachel-jahr { + font-size: 9px; + color: var(--c-text-muted); + margin-top: 2px; + line-height: 1.2; +} +.exp-kachel-add { + display: flex; + align-items: center; + gap: 2px; + font-size: 10px; + color: var(--c-text-muted); + margin-top: 3px; +} /* ---- Sektion-Block (Verlauf etc.) ---- */ .exp-section { @@ -7479,6 +7493,36 @@ svg.empty-state-icon { border-radius: 999px; padding: 1px 6px; } +.exp-dog-selector { + display: flex; + gap: 8px; + padding: 10px 16px 4px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; +} +.exp-dog-selector::-webkit-scrollbar { display: none; } +.exp-dog-pill { + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 5px 14px; + border-radius: 999px; + border: 1px solid var(--c-border); + background: var(--c-bg-card); + color: var(--c-text-secondary); + font-size: var(--text-sm); + font-weight: var(--weight-medium); + cursor: pointer; + white-space: nowrap; + transition: background .15s, color .15s, border-color .15s; +} +.exp-dog-pill.active { + background: var(--c-primary); + color: #fff; + border-color: var(--c-primary); +} /* Rechte Spalte: Betrag + Löschen-Icon */ .exp-entry-right { @@ -8133,3 +8177,189 @@ svg.empty-state-icon { 0%, 100% { opacity: 0.5; } 50% { opacity: 0.25; } } + +/* ── COMMUNITY-FEATURES ──────────────────────────────────── */ + +/* Walks-Tab-Bar */ +.walks-tab-panel { display: flex; flex-direction: column; min-height: 0; flex: 1; } + +/* Foto-Challenge */ +.challenge-banner { + background: linear-gradient(135deg, var(--c-amber, #f59e0b), var(--c-primary, #C4843A)); + border-radius: var(--radius-lg); + margin: var(--space-4); + overflow: hidden; +} +.challenge-banner-inner { + padding: var(--space-5) var(--space-4); + color: #fff; +} +.challenge-thema { + font-size: var(--text-xl); + font-weight: var(--weight-bold); + line-height: 1.2; + margin-bottom: var(--space-2); +} +.challenge-meta { + font-size: var(--text-sm); + opacity: 0.88; +} +.challenge-gallery { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: var(--space-3); + padding: 0 var(--space-4) var(--space-6); +} +.challenge-sub-card { + background: var(--c-surface); + border-radius: var(--radius-md); + overflow: hidden; + box-shadow: var(--shadow-sm); +} +.challenge-sub-card img { + width: 100%; + aspect-ratio: 1; + object-fit: cover; + display: block; + cursor: pointer; +} +.challenge-sub-info { + padding: var(--space-2); +} +.challenge-sub-user { + font-size: var(--text-xs); + color: var(--c-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 2px; +} +.challenge-sub-caption { + font-size: var(--text-xs); + color: var(--c-text); + margin-bottom: var(--space-1); + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +.challenge-vote-btn { + border: none; + background: transparent; + color: var(--c-text-secondary); + font-size: var(--text-xs); + cursor: pointer; + padding: 2px 6px; + border-radius: var(--radius-sm); + display: flex; + align-items: center; + gap: 3px; +} +.challenge-vote-btn.voted { + color: var(--c-danger, #ef4444); +} +.challenge-winners { border-top: 1px solid var(--c-border); } +.challenge-winners-row { + display: flex; + gap: var(--space-3); + overflow-x: auto; + padding: var(--space-2) var(--space-4) var(--space-3); + scroll-snap-type: x mandatory; +} +.challenge-winner-chip { + display: flex; + align-items: center; + gap: var(--space-2); + background: var(--c-surface-alt, #fdf6ef); + border-radius: var(--radius-md); + padding: var(--space-2) var(--space-3); + min-width: 160px; + flex-shrink: 0; + scroll-snap-align: start; +} +.challenge-winner-chip img { + width: 40px; + height: 40px; + border-radius: var(--radius-full); + object-fit: cover; + flex-shrink: 0; +} + +/* Wochentag-Selector */ +.wd-selector { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; +} +.wd-btn { + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + padding: 4px 10px; + border: 1.5px solid var(--c-border); + border-radius: var(--radius-full); + font-size: var(--text-sm); + user-select: none; + transition: background .15s, border-color .15s; +} +.wd-btn input { display: none; } +.wd-btn:has(input:checked) { + background: var(--c-primary, #C4843A); + border-color: var(--c-primary, #C4843A); + color: #fff; +} + +/* Gassi-Zeit-Karten */ +.gassi-zeit-card { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--c-border); + background: var(--c-surface); +} +.gz-avatar { + width: 48px; + height: 48px; + border-radius: var(--radius-full); + overflow: hidden; + flex-shrink: 0; + background: var(--c-surface-alt); + display: flex; + align-items: center; + justify-content: center; +} +.gz-avatar img { width: 100%; height: 100%; object-fit: cover; } +.gz-avatar-placeholder { font-size: 1.5rem; color: var(--c-text-secondary); } +.gz-body { flex: 1; min-width: 0; } +.gz-name { + font-weight: var(--weight-semibold); + font-size: var(--text-sm); + display: flex; + align-items: center; + gap: var(--space-1); + flex-wrap: wrap; +} +.gz-meta { font-size: var(--text-xs); color: var(--c-text-secondary); margin-top: 2px; } +.gz-notiz { font-size: var(--text-xs); color: var(--c-text-secondary); margin-top: 2px; font-style: italic; } +.gz-actions { display: flex; gap: var(--space-1); flex-shrink: 0; } + +/* Rassen-Community-Chip */ +.breed-community-chip { + display: inline-flex; + align-items: center; + gap: var(--space-1); + background: var(--c-surface-alt, #fdf6ef); + border: 1.5px solid var(--c-amber, #f59e0b); + border-radius: var(--radius-full); + padding: 6px 16px; + font-size: var(--text-sm); + font-weight: var(--weight-medium); + color: var(--c-text); + cursor: pointer; + transition: background .15s; +} +.breed-community-chip:hover, .breed-community-chip:active { + background: #fff3e0; +} diff --git a/backend/static/js/pages/dog-profile.js b/backend/static/js/pages/dog-profile.js index 09b729a..58d5aa7 100644 --- a/backend/static/js/pages/dog-profile.js +++ b/backend/static/js/pages/dog-profile.js @@ -97,8 +97,11 @@ window.Page_dog_profile = (() => {

${_esc(dog.name)}

${dog.rasse - ? `

${_esc(dog.rasse)}

` - : `

`} + ? `

${_esc(dog.rasse)}

` + : `

`} + + +
+ 🐕 ${label} — Forum ansehen + + `; + document.getElementById('dp-breed-chip-btn')?.addEventListener('click', () => { + App.navigate('forum', false, { search: hauptRasse }); + }); + } catch {} + } + + // ---------------------------------------------------------- // PUBLIC // ---------------------------------------------------------- diff --git a/backend/static/js/pages/forum.js b/backend/static/js/pages/forum.js index 8c28a1b..a76fa04 100644 --- a/backend/static/js/pages/forum.js +++ b/backend/static/js/pages/forum.js @@ -62,12 +62,21 @@ window.Page_forum = (() => { // ---------------------------------------------------------- // INIT // ---------------------------------------------------------- - async function init(container, appState) { + async function init(container, appState, params = {}) { _container = container; _appState = appState; _render(); _loadHdmCard(); _loadThreads(true); + + // Rassen-Suche vorausfüllen (Feature 3: Same-Breed-Chip) + if (params.search) { + const searchInput = document.getElementById('forum-search'); + if (searchInput) { + searchInput.value = params.search; + searchInput.dispatchEvent(new Event('input', { bubbles: true })); + } + } } function refresh() { diff --git a/backend/static/js/pages/walks.js b/backend/static/js/pages/walks.js index 02a6bb6..f07ef74 100644 --- a/backend/static/js/pages/walks.js +++ b/backend/static/js/pages/walks.js @@ -9,9 +9,12 @@ window.Page_walks = (() => { let _appState = null; let _data = []; let _view = 'liste'; // 'liste' | 'karte' + let _tab = 'treffen'; // 'treffen' | 'challenge' | 'stamm' let _map = null; let _markers = []; let _userPos = null; + let _challengeData = null; + let _gassiZeiten = []; // _esc ersetzt durch UI.escape() @@ -56,9 +59,17 @@ window.Page_walks = (() => { _loadData(); } - function refresh() { _loadData(); } + function refresh() { + _loadData(); + if (_tab === 'challenge') _loadChallenge(); + if (_tab === 'stamm') _loadGassiZeiten(); + } function onDogChange() {} - function openNew() { _showCreateForm(); } + function openNew() { + if (_tab === 'challenge') { _showSubmitForm(); return; } + if (_tab === 'stamm') { _showGassiZeitForm(); return; } + _showCreateForm(); + } // ---------------------------------------------------------- // RENDER — Grundstruktur @@ -67,30 +78,63 @@ window.Page_walks = (() => { _container.innerHTML = `
- -
-
- - -
- + +
+ + +
- -
-
+ +
+ +
+
+ + +
+ +
+ +
+
+

Lädt…

+
+
+ + +
+ + + - - `; + // Tab-Bar Events + document.getElementById('walks-tab-bar').addEventListener('click', e => { + const btn = e.target.closest('.by-tab'); + if (!btn) return; + _switchTab(btn.dataset.tab); + }); + document.getElementById('walks-view-toggle').addEventListener('click', e => { const btn = e.target.closest('.walks-view-btn'); if (!btn) return; @@ -105,6 +149,23 @@ window.Page_walks = (() => { } _showCreateForm(); }); + + document.getElementById('gassi-zeit-add-btn').addEventListener('click', () => { + if (!_appState.user) { UI.toast.warning('Bitte zuerst anmelden.'); App.navigate('settings'); return; } + _showGassiZeitForm(); + }); + } + + function _switchTab(tab) { + _tab = tab; + document.querySelectorAll('.by-tab').forEach(b => + b.classList.toggle('active', b.dataset.tab === tab)); + document.querySelectorAll('.walks-tab-panel').forEach(p => p.style.display = 'none'); + const panel = document.getElementById(`walks-tab-${tab}`); + if (panel) panel.style.display = ''; + + if (tab === 'challenge' && !_challengeData) _loadChallenge(); + if (tab === 'stamm' && !_gassiZeiten.length) _loadGassiZeiten(); } function _switchView(view) { @@ -1038,6 +1099,375 @@ window.Page_walks = (() => { }); } + // ============================================================== + // FEATURE 1: Foto-Challenge der Woche + // ============================================================== + + async function _loadChallenge() { + const el = document.getElementById('challenge-content'); + if (!el) return; + try { + _challengeData = await API.get('challenges/current'); + _renderChallenge(); + } catch (e) { + el.innerHTML = `

Konnte Challenge nicht laden.

`; + } + } + + function _renderChallenge() { + const el = document.getElementById('challenge-content'); + if (!el || !_challengeData) return; + const { challenge, submissions, my_submission_id, days_left } = _challengeData; + + const canSubmit = _appState.user && !my_submission_id; + const dayLabel = days_left === 1 ? '1 Tag' : `${days_left} Tage`; + + el.innerHTML = ` +
+
+
${UI.escape(challenge.thema)}
+
+ ${UI.icon('calendar')} ${_fmtDate(challenge.start_date)} – ${_fmtDate(challenge.end_date)} +  ·  ${UI.icon('timer')} Noch ${dayLabel} +
+ ${canSubmit ? `` : ''} + ${my_submission_id ? `${UI.icon('check')} Du hast bereits teilgenommen` : ''} +
+
+ +
+

Letzte Gewinner

+

Lädt…

+
+ +
+

+ ${UI.icon('images')} Einreichungen dieser Woche (${submissions.length}) +

+
+ + `; + + // Submit-Button + const submitBtn = document.getElementById('challenge-submit-btn'); + if (submitBtn) submitBtn.addEventListener('click', _showSubmitForm); + + // Vote-Buttons + el.querySelectorAll('.challenge-vote-btn').forEach(btn => { + btn.addEventListener('click', async () => { + if (!_appState.user) { UI.toast.warning('Bitte anmelden.'); return; } + const subId = parseInt(btn.dataset.id); + try { + const res = await API.post(`challenges/submissions/${subId}/vote`, {}); + btn.querySelector('.vote-count').textContent = res.votes; + btn.classList.toggle('voted', res.voted); + } catch (e) { UI.toast.error(e.message || 'Fehler beim Abstimmen.'); } + }); + }); + + // Gewinner laden + _loadChallengeWinners(); + } + + function _challengeSubmissionCard(s) { + const voted = s.i_voted; + return ` +
+ Challenge-Foto +
+
${UI.icon('user')} ${UI.escape(s.user_name || 'Anonym')} + ${s.dog_name ? ` · 🐕 ${UI.escape(s.dog_name)}` : ''}
+ ${s.caption ? `
${UI.escape(s.caption)}
` : ''} + +
+
+ `; + } + + async function _loadChallengeWinners() { + const el = document.getElementById('challenge-winners-list'); + if (!el) return; + try { + const winners = await API.get('challenges/winners'); + if (!winners.length) { el.innerHTML = '

Noch keine vergangenen Challenges.

'; return; } + el.innerHTML = `
` + + winners.map(w => { + if (!w.winner) return `
${UI.escape(w.challenge.thema)}Kein Gewinner
`; + return `
+ Gewinner +
+
${UI.escape(w.challenge.thema)}
+
${UI.escape(w.winner.user_name)} · ${w.winner.votes} ❤️
+
+
`; + }).join('') + + `
`; + } catch {} + } + + async function _showSubmitForm() { + if (!_challengeData) return; + const dogs = _appState.dogs || []; + const dogOptions = dogs.map(d => ``).join(''); + + UI.modal.open({ + title: `📸 ${UI.escape(_challengeData.challenge.thema)}`, + body: ` +
+
+ + +
+ ${dogs.length ? `
+ + +
` : ''} +
+ + +
+
+ `, + footer: ` + + + `, + }); + + document.getElementById('challenge-submit-ok').addEventListener('click', async () => { + const fotoInput = document.getElementById('challenge-foto-input'); + if (!fotoInput.files.length) { UI.toast.warning('Bitte ein Foto auswählen.'); return; } + const caption = document.getElementById('challenge-caption')?.value?.trim() || ''; + const dogSelect = document.getElementById('challenge-dog-select'); + const dogId = dogSelect ? (parseInt(dogSelect.value) || '') : ''; + + const fd = new FormData(); + fd.append('foto', fotoInput.files[0]); + if (caption) fd.append('caption', caption); + if (dogId) fd.append('dog_id', dogId); + + try { + await API.upload(`challenges/${_challengeData.challenge.id}/submit`, fd); + UI.toast.success('Foto eingereicht! Viel Erfolg 🎉'); + UI.modal.close(); + _challengeData = null; + _loadChallenge(); + } catch (e) { UI.toast.error(e.message || 'Fehler beim Einreichen.'); } + }); + } + + + // ============================================================== + // FEATURE 2: Gassi-Zeiten-Pool (Stamm-Gassis) + // ============================================================== + + const _WOCHENTAGE = [ + { key: 'mo', label: 'Mo' }, { key: 'di', label: 'Di' }, { key: 'mi', label: 'Mi' }, + { key: 'do', label: 'Do' }, { key: 'fr', label: 'Fr' }, { key: 'sa', label: 'Sa' }, + { key: 'so', label: 'So' }, + ]; + + async function _loadGassiZeiten() { + const el = document.getElementById('gassi-zeiten-content'); + if (!el) return; + try { + const params = _userPos ? `?lat=${_userPos.lat}&lon=${_userPos.lon}&radius=10000` : ''; + _gassiZeiten = await API.get(`gassi-zeiten${params}`); + _renderGassiZeiten(); + } catch (e) { + el.innerHTML = `

Konnte Gassi-Zeiten nicht laden.

`; + } + } + + function _renderGassiZeiten() { + const el = document.getElementById('gassi-zeiten-content'); + if (!el) return; + + if (!_gassiZeiten.length) { + el.innerHTML = ` +
+ ${UI.icon('clock')} +

Noch keine Stamm-Gassi-Zeiten in deiner Nähe.

+

Trag deine regelmäßigen Zeiten ein — andere finden dich dann!

+
`; + return; + } + + const myZeiten = _gassiZeiten.filter(z => z.is_mine); + const andereZeiten = _gassiZeiten.filter(z => !z.is_mine); + + let html = ''; + + if (myZeiten.length) { + html += `
Meine Zeiten
`; + html += myZeiten.map(z => _gassiZeitCard(z)).join(''); + } + + if (andereZeiten.length) { + html += `
In deiner Nähe
`; + html += andereZeiten.map(z => _gassiZeitCard(z)).join(''); + } + + el.innerHTML = html; + + // Events + el.querySelectorAll('.gz-delete-btn').forEach(btn => { + btn.addEventListener('click', async () => { + if (!confirm('Gassi-Zeit löschen?')) return; + try { + await API.del(`gassi-zeiten/${btn.dataset.id}`); + _gassiZeiten = _gassiZeiten.filter(z => z.id !== parseInt(btn.dataset.id)); + _renderGassiZeiten(); + UI.toast.success('Gelöscht.'); + } catch (e) { UI.toast.error(e.message || 'Fehler.'); } + }); + }); + + el.querySelectorAll('.gz-toggle-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const gz = _gassiZeiten.find(z => z.id === parseInt(btn.dataset.id)); + if (!gz) return; + try { + const updated = await API.patch(`gassi-zeiten/${gz.id}`, { aktiv: gz.aktiv ? 0 : 1 }); + const idx = _gassiZeiten.findIndex(z => z.id === gz.id); + if (idx !== -1) _gassiZeiten[idx] = { ..._gassiZeiten[idx], aktiv: updated.aktiv }; + _renderGassiZeiten(); + } catch (e) { UI.toast.error(e.message || 'Fehler.'); } + }); + }); + + el.querySelectorAll('.gz-chat-btn').forEach(btn => { + btn.addEventListener('click', () => { + const userId = parseInt(btn.dataset.userId); + App.navigate('chat', { user_id: userId }); + }); + }); + } + + function _gassiZeitCard(z) { + const wochentageLabel = (z.wochentage || []).join(', ').toUpperCase(); + const distLabel = z.distance_m != null + ? `${z.distance_m < 1000 ? z.distance_m + ' m' : (z.distance_m/1000).toFixed(1) + ' km'}` + : ''; + const pausedStyle = z.aktiv ? '' : 'opacity:.5'; + + return ` +
+
+ ${z.dog_foto_url + ? `${UI.escape(z.dog_name || '')}` + : `
${UI.icon('paw-print')}
`} +
+
+
${UI.escape(z.dog_name || z.user_name || '?')} + ${z.dog_rasse ? `${UI.escape(z.dog_rasse)}` : ''} + ${!z.aktiv ? `Pausiert` : ''} +
+
+ ${UI.icon('clock')} ${UI.escape(z.uhrzeit)} +  ·  ${wochentageLabel} + ${z.ort_name ? ` ·  ${UI.icon('map-pin')} ${UI.escape(z.ort_name)}` : ''} + ${distLabel} +
+ ${z.notiz ? `
${UI.escape(z.notiz)}
` : ''} +
+
+ ${z.is_mine ? ` + + + ` : ` + + `} +
+
+ `; + } + + async function _showGassiZeitForm() { + const dogs = _appState.dogs || []; + const dogOptions = dogs.map(d => ``).join(''); + const wdBtns = _WOCHENTAGE.map(w => + `` + ).join(''); + + UI.modal.open({ + title: `${UI.icon('clock')} Stamm-Gassi-Zeit eintragen`, + body: ` +
+ ${dogs.length ? `
+ + +
` : ''} +
+ + +
+
+ +
${wdBtns}
+
+
+ + +
+
+ + +
+
+ `, + footer: ` + + + `, + }); + + document.getElementById('gz-save-btn').addEventListener('click', async () => { + const uhrzeit = document.getElementById('gz-uhrzeit')?.value; + if (!uhrzeit) { UI.toast.warning('Bitte Uhrzeit angeben.'); return; } + + const wochentage = Array.from(document.querySelectorAll('.wd-btn input:checked')).map(cb => cb.value); + if (!wochentage.length) { UI.toast.warning('Bitte mindestens einen Wochentag wählen.'); return; } + + const dogId = parseInt(document.getElementById('gz-dog-select')?.value) || null; + const ortName = document.getElementById('gz-ort-name')?.value?.trim() || null; + const notiz = document.getElementById('gz-notiz')?.value?.trim() || null; + + const payload = { wochentage, uhrzeit, ort_name: ortName, notiz }; + if (dogId) payload.dog_id = dogId; + if (_userPos) { payload.lat = _userPos.lat; payload.lon = _userPos.lon; } + + try { + const created = await API.post('gassi-zeiten', payload); + _gassiZeiten.unshift({ ...created }); + _renderGassiZeiten(); + UI.toast.success('Gassi-Zeit eingetragen! 🐾'); + UI.modal.close(); + } catch (e) { UI.toast.error(e.message || 'Fehler beim Speichern.'); } + }); + } + + return { init, refresh, onDogChange, openNew, openDetail: _openDetail }; })(); diff --git a/backend/static/sw.js b/backend/static/sw.js index 883e797..a80b366 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -10,9 +10,9 @@ const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache // index.html wird NICHT pre-gecacht (immer Network-First) const STATIC_ASSETS = [ - '/css/design-system.css?v=545', - '/css/layout.css?v=545', - '/css/components.css?v=545', + '/css/design-system.css?v=700', + '/css/layout.css?v=700', + '/css/components.css?v=700', '/icons/phosphor.svg', '/js/api.js', '/js/ui.js',