From 857c83bd65c4dec529b2a7b357641746cf18dad3 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 8 May 2026 13:07:06 +0200 Subject: [PATCH 01/10] =?UTF-8?q?Fix:=20Giftliste=20Dark=20Mode=20?= =?UTF-8?q?=E2=80=94=20CSS-Variablen=20statt=20hardcoded=20#fff5f5/#fff3cd?= =?UTF-8?q?,=20--c-danger-border=20neu=20(SW=20by-v775)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 2 +- backend/static/css/design-system.css | 2 ++ backend/static/index.html | 2 +- backend/static/js/app.js | 2 +- backend/static/js/pages/ernaehrung.js | 15 +++++++++------ backend/static/sw.js | 2 +- 6 files changed, 15 insertions(+), 10 deletions(-) diff --git a/backend/main.py b/backend/main.py index 4fdbfae..e6f1d30 100644 --- a/backend/main.py +++ b/backend/main.py @@ -327,7 +327,7 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") os.makedirs(MEDIA_DIR, exist_ok=True) app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") -APP_VER = "774" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "775" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/css/design-system.css b/backend/static/css/design-system.css index cc9c40f..8683ab0 100644 --- a/backend/static/css/design-system.css +++ b/backend/static/css/design-system.css @@ -39,6 +39,7 @@ --c-danger: #C4391A; --c-danger-dark: #9E2A10; --c-danger-subtle: #FDEEE9; + --c-danger-border: rgba(196,57,26,0.3); --c-success: #5B8A4A; --c-success-subtle: #EBF4E7; --c-warning: #D4923A; @@ -132,6 +133,7 @@ --c-nature-subtle: #1A2214; --c-sky-subtle: #141C22; --c-danger-subtle: #2A100A; + --c-danger-border: rgba(196,57,26,0.4); --c-success-subtle: #122010; --c-warning-subtle: #261A08; --c-info-subtle: #10182A; diff --git a/backend/static/index.html b/backend/static/index.html index 856efe8..27c1028 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -578,7 +578,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 9887ed7..88eb9e0 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '774'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '775'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.5.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/pages/ernaehrung.js b/backend/static/js/pages/ernaehrung.js index be58151..7cf595f 100644 --- a/backend/static/js/pages/ernaehrung.js +++ b/backend/static/js/pages/ernaehrung.js @@ -460,22 +460,25 @@ window.Page_ernaehrung = (() => { el.innerHTML = `
-
+
⚠️ Notfall-Tierarzt: Bei Verdacht auf Vergiftung sofort zum Tierarzt. Nicht abwarten, auch wenn noch keine Symptome sichtbar sind.
${items.map(item => ` -
${item.emoji}
-
${_esc(item.name)}
-
${_esc(item.grund)}
+
${_esc(item.name)}
+
${_esc(item.grund)}
diff --git a/backend/static/sw.js b/backend/static/sw.js index 171d7f4..9cf2a4a 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v774'; +const CACHE_VERSION = 'by-v775'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 662190e3080d74e93b170531745b8fb9afa6d87f Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 8 May 2026 13:16:51 +0200 Subject: [PATCH 02/10] =?UTF-8?q?Feature:=20Geburtstags-Banner=20in=20HUND?= =?UTF-8?q?-Welt=20=E2=80=94=20heute=20&=20morgen,=20mit=20Alter=20und=20A?= =?UTF-8?q?nimation=20(SW=20by-v776)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 2 +- backend/static/index.html | 2 +- backend/static/js/app.js | 2 +- backend/static/js/worlds.js | 31 +++++++++++++++++++++++++++++++ backend/static/sw.js | 2 +- 5 files changed, 35 insertions(+), 4 deletions(-) diff --git a/backend/main.py b/backend/main.py index e6f1d30..a3f8685 100644 --- a/backend/main.py +++ b/backend/main.py @@ -327,7 +327,7 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") os.makedirs(MEDIA_DIR, exist_ok=True) app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") -APP_VER = "775" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "776" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/index.html b/backend/static/index.html index 27c1028..74e7a38 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -578,7 +578,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 88eb9e0..aedf1a8 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '775'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '776'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.5.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js index 4fcd9de..8086b5b 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -1236,6 +1236,23 @@ window.Worlds = (() => { if (_dogIdx >= _dogs.length) _dogIdx = 0; const dog = _dogs[_dogIdx]; + // Geburtstag prüfen (heute oder morgen → Feature sichtbar) + function _birthdayState(geb) { + if (!geb) return null; + const today = new Date(); + const tomorrow = new Date(today); tomorrow.setDate(today.getDate() + 1); + const mm = String(today.getMonth() + 1).padStart(2, '0'); + const dd = String(today.getDate()).padStart(2, '0'); + const mt = String(tomorrow.getMonth() + 1).padStart(2, '0'); + const dt = String(tomorrow.getDate()).padStart(2, '0'); + const mmdd = geb.slice(5); // 'MM-DD' + if (mmdd === `${mm}-${dd}`) return 'today'; + if (mmdd === `${mt}-${dt}`) return 'tomorrow'; + return null; + } + const bday = _birthdayState(dog.geburtstag); + const bdayYear = dog.geburtstag ? new Date().getFullYear() - parseInt(dog.geburtstag.slice(0, 4)) : null; + const [streakRes, diaryRes] = await Promise.allSettled([ _cachedGet(`streak_${dog.id}`, `/streak/${dog.id}`), _cachedGet(`diary_${dog.id}`, `/dogs/${dog.id}/diary?limit=1`), @@ -1294,6 +1311,20 @@ window.Worlds = (() => {
${otherAvatarsHtml}
+ ${bday ? ` +
+
${bday === 'today' ? '🎂' : '🎁'}
+
+ ${bday === 'today' + ? `Alles Gute zum ${bdayYear}. Geburtstag, ${_esc(dog.name)}! 🎉` + : `Morgen hat ${_esc(dog.name)} Geburtstag! 🥳`} +
+ ${bdayYear ? `
+ ${bday === 'today' ? `${bdayYear} Jahr${bdayYear !== 1 ? 'e' : ''} gemeinsam` : `Wird ${bdayYear} Jahr${bdayYear !== 1 ? 'e' : ''} alt`} +
` : ''} +
` : ''}
${!_hasBgPhoto ? ` diff --git a/backend/static/sw.js b/backend/static/sw.js index 9cf2a4a..63dcb19 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v775'; +const CACHE_VERSION = 'by-v776'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 55ae22615ddc5e0d949683c19fac46c7ed166c9d Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 8 May 2026 13:20:03 +0200 Subject: [PATCH 03/10] Feature: Geburtstags-Banner mit Feuerwerk-Animationen im world-reminder Stil (SW by-v777) --- backend/main.py | 2 +- backend/static/index.html | 2 +- backend/static/js/app.js | 2 +- backend/static/js/worlds.js | 37 +++++++++++++++++++++++++++++-------- backend/static/sw.js | 2 +- 5 files changed, 33 insertions(+), 12 deletions(-) diff --git a/backend/main.py b/backend/main.py index a3f8685..dcf19f7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -327,7 +327,7 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") os.makedirs(MEDIA_DIR, exist_ok=True) app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") -APP_VER = "776" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "777" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/index.html b/backend/static/index.html index 74e7a38..4b0cf7d 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -578,7 +578,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index aedf1a8..b3d9d01 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '776'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '777'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.5.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js index 8086b5b..25a824b 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -1312,17 +1312,38 @@ window.Worlds = (() => {
${bday ? ` -
-
${bday === 'today' ? '🎂' : '🎁'}
-
+ +
+
+ 🎆 + ${bday === 'today' ? '🎂' : '🎁'} + 🎇 +
+
${bday === 'today' - ? `Alles Gute zum ${bdayYear}. Geburtstag, ${_esc(dog.name)}! 🎉` + ? `Alles Gute zum ${bdayYear}. Geburtstag, ${_esc(dog.name)}!` : `Morgen hat ${_esc(dog.name)} Geburtstag! 🥳`}
- ${bdayYear ? `
- ${bday === 'today' ? `${bdayYear} Jahr${bdayYear !== 1 ? 'e' : ''} gemeinsam` : `Wird ${bdayYear} Jahr${bdayYear !== 1 ? 'e' : ''} alt`} +
+ 🎉🎊🎉 +
+ ${bdayYear ? `
+ ${bday === 'today' ? `${bdayYear} Jahr${bdayYear !== 1 ? 'e' : ''} gemeinsam 🐾` : `Wird ${bdayYear} Jahr${bdayYear !== 1 ? 'e' : ''} alt`}
` : ''}
` : ''}
diff --git a/backend/static/sw.js b/backend/static/sw.js index 63dcb19..58e5d1b 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v776'; +const CACHE_VERSION = 'by-v777'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From f02b9aa4abb3b9f1fba5aff35652b58b9e4b4e2d Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 8 May 2026 13:28:12 +0200 Subject: [PATCH 04/10] =?UTF-8?q?Fix:=20VDH-Scraper=20URLs=20aktualisiert?= =?UTF-8?q?=20=E2=80=94=20/veranstaltungen/=20=E2=86=92=20/ausstellungen/?= =?UTF-8?q?=20+=20/hundesport/termine/=20(SW=20by-v777)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/scraper/events_vdh.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/scraper/events_vdh.py b/backend/scraper/events_vdh.py index 65821ee..afdea43 100644 --- a/backend/scraper/events_vdh.py +++ b/backend/scraper/events_vdh.py @@ -232,8 +232,9 @@ async def fetch_vdh_events() -> list[dict]: Bei Fehler oder 0 Ergebnissen: Fallback auf FALLBACK_EVENTS. """ urls = [ - "https://www.vdh.de/veranstaltungen/ausstellungen/", - "https://www.vdh.de/veranstaltungen/", + "https://www.vdh.de/ausstellungen/liste/typ/spezial/", + "https://www.vdh.de/ausstellungen/", + "https://www.vdh.de/hundesport/termine/", ] headers = { From bff54dcfd3408304eec238abb6d09ec82f2b283f Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 8 May 2026 13:34:13 +0200 Subject: [PATCH 05/10] =?UTF-8?q?Fix:=20VDH-Scraper=20komplett=20neu=20?= =?UTF-8?q?=E2=80=94=20dedizierte=20Parser=20f=C3=BCr=20/ausstellungen/lis?= =?UTF-8?q?te/=20und=20/hundesport/termine/=20(neue=20HTML-Struktur)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/scraper/events_vdh.py | 356 ++++++++++++++++++---------------- 1 file changed, 188 insertions(+), 168 deletions(-) diff --git a/backend/scraper/events_vdh.py b/backend/scraper/events_vdh.py index afdea43..020908c 100644 --- a/backend/scraper/events_vdh.py +++ b/backend/scraper/events_vdh.py @@ -21,21 +21,13 @@ FALLBACK_EVENTS = [ {"titel": "Hundesport-Turnier Berlin", "datum": "2026-09-12", "ort_name": "Berlin", "typ": "wettkampf", "link": "https://www.vdh.de", "external_id": "vdh-fallback-berlin-2026"}, ] -# Mapping VDH-Kategorienamen → interne Typen _TYP_MAP = { - "ausstellung": "ausstellung", - "show": "ausstellung", - "siegershow": "ausstellung", - "agility": "wettkampf", - "wettkampf": "wettkampf", - "turnier": "wettkampf", - "prüfung": "wettkampf", - "training": "training", - "treffen": "treffen", - "markt": "markt", + "ausstellung": "ausstellung", "show": "ausstellung", "siegershow": "ausstellung", + "agility": "wettkampf", "wettkampf": "wettkampf", "turnier": "wettkampf", + "prüfung": "wettkampf", "meisterschaft": "wettkampf", + "training": "training", "treffen": "treffen", "markt": "markt", } -# Monatsnamen Deutsch → Zahl _MONATE = { "januar": 1, "februar": 2, "märz": 3, "maerz": 3, "april": 4, "mai": 5, "juni": 6, "juli": 7, @@ -45,171 +37,217 @@ _MONATE = { def _guess_typ(text: str) -> str: - """Bestimmt den Event-Typ anhand des Titels.""" t = text.lower() for keyword, typ in _TYP_MAP.items(): if keyword in t: return typ - return "sonstiges" + return "ausstellung" def _parse_date(raw: str) -> str | None: - """ - Versucht verschiedene Datumsformate zu parsen. - Gibt YYYY-MM-DD zurück oder None. - """ raw = raw.strip() - + # Datumsbereich: "DD.MM.YYYY - DD.MM.YYYY" → erstes Datum nehmen + raw = raw.split(" - ")[0].strip() # ISO: 2026-05-03 m = re.match(r'^(\d{4})-(\d{2})-(\d{2})$', raw) if m: return raw - # DD.MM.YYYY oder D.M.YYYY m = re.match(r'^(\d{1,2})\.(\d{1,2})\.(\d{4})$', raw) if m: d, mo, y = m.groups() return f"{y}-{int(mo):02d}-{int(d):02d}" - - # DD. Monatsname YYYY (z.B. "14. Juni 2026") + # DD. Monatsname YYYY m = re.match(r'^(\d{1,2})\.\s*(\w+)\s+(\d{4})$', raw) if m: d, mon_str, y = m.groups() mon_num = _MONATE.get(mon_str.lower()) if mon_num: return f"{y}-{mon_num:02d}-{int(d):02d}" - - # Monatsname DD, YYYY (englisch, Fallback) - try: - dt = datetime.strptime(raw, "%B %d, %Y") - return dt.strftime("%Y-%m-%d") - except ValueError: - pass - return None -class _VDHParser(HTMLParser): - """ - Einfacher Zustandsautomat-Parser für die VDH-Veranstaltungsseite. - Sucht nach typischen Strukturen: article, li.event, div mit Datums-/Titel-Klassen. - """ +# ── PARSER 1: /ausstellungen/liste/typ/spezial/ ────────────────────────────── +# Struktur: div.ausstellung_liste > div.row > div.span6 +# Linke span6: Rassen
DD.MM.YYYY
Verein
Straße
PLZ Ort
+class _SpezialParser(HTMLParser): def __init__(self): super().__init__() - self._events: list[dict] = [] - self._current: dict | None = None - self._depth = 0 - self._start_depth = 0 - self._capture = None # 'titel' | 'datum' | 'ort' - self._buf = "" - self._in_event = False - - # ---------- Hilfsmethoden ---------- - - def _is_event_container(self, tag, attrs): - """Erkennt Start eines Event-Blocks.""" - a = dict(attrs) - cls = a.get("class", "") - return ( - tag == "article" - or (tag in ("li", "div") and any( - kw in cls for kw in ("event", "veranstaltung", "termin", "entry", "item") - )) - ) - - def _is_title_tag(self, tag, attrs): - a = dict(attrs) - cls = a.get("class", "") - return tag in ("h2", "h3", "h4") or any( - kw in cls for kw in ("title", "titel", "name", "heading") - ) - - def _is_date_tag(self, tag, attrs): - a = dict(attrs) - cls = a.get("class", "") - it = a.get("itemprop", "") - return ( - tag in ("time",) - or any(kw in cls for kw in ("date", "datum", "time")) - or it in ("startDate", "endDate") - ) - - def _is_location_tag(self, tag, attrs): - a = dict(attrs) - cls = a.get("class", "") - it = a.get("itemprop", "") - return ( - any(kw in cls for kw in ("location", "ort", "venue", "place", "city")) - or it in ("location", "addressLocality") - ) - - # ---------- SAX-Events ---------- + self._events = [] + self._in_liste = False + self._row_d = 0 # depth beim row-Start + self._span_d = 0 # depth beim span6-Start + self._depth = 0 + self._in_row = False + self._in_span = False # linke span6 (erste im row) + self._span_done = False # linke span6 fertig geparst + self._in_b = False + self._buf = "" + self._parts: list[str] = [] # Teile zwischen
+ self._title = "" def handle_starttag(self, tag, attrs): self._depth += 1 a = dict(attrs) + cls = a.get("class", "") - if not self._in_event and self._is_event_container(tag, attrs): - self._in_event = True - self._start_depth = self._depth - self._current = {"titel": "", "datum": "", "ort_name": "", "link": ""} - # Direkter Link auf dem Container? - if tag == "a" and "href" in a: - self._current["link"] = a["href"] - return + if "ausstellung_liste" in cls: + self._in_liste = True - if self._in_event: - # Link innerhalb des Event-Blocks - if tag == "a" and "href" in a and not self._current.get("link"): - href = a["href"] - if "vdh.de" in href or href.startswith("/"): - self._current["link"] = href + if self._in_liste and tag == "div" and "row" in cls.split(): + self._in_row = True + self._row_d = self._depth + self._span_done = False + self._title = "" + self._parts = [] - #