From ea2a83b29e8a5ff4bec4174f694955e34457079e Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 2 May 2026 08:12:29 +0200 Subject: [PATCH] Feature: Filme-Suche, HdM ins Forum + Gewinner-Badge im Profil, SW by-v594 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Filme-Seite: Suchfeld (filtert live nach Titel, Rasse, Genre, Beschreibung) - Filme-Seite: Tab "Hund des Monats" entfernt - Forum: kompakte HdM-Kachel über der Suche (Sieger + Stimmen), Klick öffnet Abstimmungs-Modal - Hundeprofil: goldene Badges für jeden gewonnenen Monat (🏆 Mai 2026 …) - DB: Tabelle hund_des_monats_wins (dauerhaft, dog_id + monat + stimmen) - Scheduler: Job am 1. des Monats 00:05 — schreibt Vormonats-Sieger, Push an Besitzer - Dogs-API: liefert hdm_wins[] pro Hund mit --- backend/database.py | 13 +++ backend/routes/dogs.py | 15 +++ backend/scheduler.py | 62 +++++++++++ backend/static/css/components.css | 87 ++++++++++++++++ backend/static/js/app.js | 2 +- backend/static/js/pages/dog-profile.js | 15 ++- backend/static/js/pages/forum.js | 138 ++++++++++++++++++++++++- backend/static/js/pages/movies.js | 21 +++- backend/static/sw.js | 2 +- 9 files changed, 348 insertions(+), 7 deletions(-) diff --git a/backend/database.py b/backend/database.py index b7b5114..e373f02 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1644,3 +1644,16 @@ def _migrate(conn_factory): conn.execute("UPDATE training_exercises SET js_exercise_id=? WHERE id=?", (js_id, row['id'])) conn.execute("CREATE INDEX IF NOT EXISTS idx_te_js_id ON training_exercises(js_exercise_id)") logger.info("Migration: training_exercises.js_exercise_id hinzugefügt, 'Fuß' bereinigt.") + + # Hund des Monats — dauerhafte Gewinner-Tabelle + conn.executescript(""" + CREATE TABLE IF NOT EXISTS hund_des_monats_wins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + monat TEXT NOT NULL, + stimmen INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(dog_id, monat) + ); + CREATE INDEX IF NOT EXISTS idx_hdm_wins_dog ON hund_des_monats_wins(dog_id); + """) diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index 74f1c95..c7a9066 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -75,6 +75,21 @@ async def list_dogs(user=Depends(get_current_user)): d = dict(r) d["is_guest"] = True result.append(d) + + # HdM-Siege pro Hund anhängen + if result: + dog_ids = [d["id"] for d in result] + with db() as conn: + wins_rows = conn.execute( + f"SELECT dog_id, monat FROM hund_des_monats_wins WHERE dog_id IN ({','.join('?'*len(dog_ids))}) ORDER BY monat DESC", + dog_ids, + ).fetchall() + wins_map: dict[int, list[str]] = {} + for w in wins_rows: + wins_map.setdefault(w["dog_id"], []).append(w["monat"]) + for d in result: + d["hdm_wins"] = wins_map.get(d["id"], []) + return result diff --git a/backend/scheduler.py b/backend/scheduler.py index a4ce739..68a4c07 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -124,6 +124,14 @@ def start(): replace_existing=True, misfire_grace_time=3600, ) + # 1. des Monats 00:05 — Hund des Monats Sieger festlegen + _scheduler.add_job( + _job_hdm_winner, + CronTrigger(day=1, hour=0, minute=5), + id="hdm_winner", + replace_existing=True, + 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. OSM-Cache: on-demand (kein Prewarm).") @@ -1110,3 +1118,57 @@ def _compute_milestone(today: date, bday: date, dog_name: str): return titel, text return None + + +# ------------------------------------------------------------------ +# JOB: Hund des Monats — Sieger des Vormonats festlegen +# ------------------------------------------------------------------ +async def _job_hdm_winner(): + """Läuft am 1. des Monats 00:05 und schreibt den Sieger des Vormonats.""" + today = datetime.now(tz=_TZ) + # Vormonat berechnen + first_this = today.replace(day=1) + last_month = (first_this - timedelta(days=1)).replace(day=1) + monat = last_month.strftime("%Y-%m") + + with db() as conn: + # Schon eingetragen? + existing = conn.execute( + "SELECT id FROM hund_des_monats_wins WHERE monat=?", (monat,) + ).fetchone() + if existing: + logger.info(f"HdM-Winner {monat}: bereits eingetragen, übersprungen.") + _log_job("hdm_winner", "ok", f"bereits vorhanden für {monat}") + return + + winner = conn.execute(""" + SELECT v.dog_id, d.name, d.user_id, COUNT(v.id) AS stimmen + FROM hund_des_monats_votes v + JOIN dogs d ON d.id = v.dog_id + WHERE v.monat = ? + GROUP BY v.dog_id + ORDER BY stimmen DESC + LIMIT 1 + """, (monat,)).fetchone() + + if not winner: + logger.info(f"HdM-Winner {monat}: keine Stimmen, kein Sieger.") + _log_job("hdm_winner", "ok", f"keine Stimmen für {monat}") + return + + conn.execute( + "INSERT OR IGNORE INTO hund_des_monats_wins (dog_id, monat, stimmen) VALUES (?, ?, ?)", + (winner["dog_id"], monat, winner["stimmen"]), + ) + + month_label = last_month.strftime("%B %Y") + send_push_to_user(winner["user_id"], { + "type": "hdm_winner", + "title": f"🏆 {winner['name']} ist Hund des Monats!", + "body": f"{winner['name']} hat den {month_label} gewonnen — herzlichen Glückwunsch!", + "data": {"page": "forum"}, + "tag": f"hdm-{monat}", + }) + + logger.info(f"HdM-Winner {monat}: Hund {winner['dog_id']} ('{winner['name']}', {winner['stimmen']} Stimmen) eingetragen.") + _log_job("hdm_winner", "ok", f"{monat}: {winner['name']} ({winner['stimmen']} Stimmen)") diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 88d1b0d..65e9739 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -4200,6 +4200,59 @@ html.modal-open { .forum-category-badge--tauschboerse { background: #fce7f3; color: #9d174d; } /* Search */ +/* Hund des Monats — kompakte Forum-Kachel */ +.forum-hdm-tile { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + background: linear-gradient(135deg, var(--c-surface-2) 0%, var(--c-bg) 100%); + border: 1.5px solid var(--c-border-light); + border-radius: var(--radius-lg); + cursor: pointer; + margin-bottom: var(--space-3); + min-width: 0; + transition: border-color .15s, box-shadow .15s; +} +.forum-hdm-tile:hover { border-color: var(--c-primary); box-shadow: var(--shadow-sm); } +.forum-hdm-tile-trophy { font-size: 1.5rem; flex-shrink: 0; } +.forum-hdm-tile-body { + flex: 1; + min-width: 0; +} +.forum-hdm-tile-title { + font-size: var(--text-xs); + font-weight: var(--weight-semibold); + color: var(--c-text-muted); + text-transform: uppercase; + letter-spacing: .04em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.forum-hdm-tile-winner { + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + color: var(--c-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.forum-hdm-tile-meta { + font-size: var(--text-xs); + color: var(--c-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.forum-hdm-tile-cta { + flex-shrink: 0; + font-size: var(--text-xs); + font-weight: var(--weight-semibold); + color: var(--c-primary); + white-space: nowrap; +} + .forum-search-wrap { position: relative; } @@ -4918,6 +4971,25 @@ html.modal-open { } /* Filter-Row */ +.movies-search-row { + position: relative; + padding: var(--space-3) 0 var(--space-1); +} +.movies-search-icon { + position: absolute; + left: var(--space-3); + top: 50%; + transform: translateY(-50%); + width: 16px; + height: 16px; + color: var(--c-text-muted); + pointer-events: none; +} +.movies-search-input { + padding-left: calc(var(--space-3) + 16px + var(--space-2)) !important; + font-size: var(--text-sm); +} + .movies-filter-row { display: flex; gap: var(--space-2); @@ -5972,6 +6044,21 @@ html.modal-open { cursor: pointer; } +/* Hund des Monats — Profil-Badge */ +.dp-hdm-badge { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-1) var(--space-3); + background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%); + color: #78350f; + border-radius: var(--radius-full); + font-size: var(--text-xs); + font-weight: var(--weight-semibold); + letter-spacing: .02em; + box-shadow: 0 1px 3px rgba(0,0,0,.15); +} + /* --- Foto-Editor Modal --- */ .photo-editor { display: flex; flex-direction: column; gap: var(--space-3); align-items: center; } .photo-editor-preview { diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 20021b5..ba16c57 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 = '591'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '594'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/dog-profile.js b/backend/static/js/pages/dog-profile.js index 953232e..852237d 100644 --- a/backend/static/js/pages/dog-profile.js +++ b/backend/static/js/pages/dog-profile.js @@ -97,8 +97,19 @@ window.Page_dog_profile = (() => {

${_esc(dog.name)}

${dog.rasse - ? `

${_esc(dog.rasse)}

` - : `

`} + ? `

${_esc(dog.rasse)}

` + : `

`} + + ${(dog.hdm_wins?.length) ? ` +
+ ${dog.hdm_wins.map(m => { + const [y, mo] = m.split('-'); + const label = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' }) + .format(new Date(+y, +mo - 1, 1)); + return `🏆 ${label}`; + }).join('')} +
+ ` : `
`}
${UI.icon('users')} Mitgliederkarte
- +
+
+
@@ -175,6 +178,139 @@ window.Page_forum = (() => { document.getElementById('forum-rules-btn').addEventListener('click', _showRules); } + // ---------------------------------------------------------- + // Hund des Monats — Kachel + Modal + // ---------------------------------------------------------- + async function _loadHdmCard() { + const card = document.getElementById('forum-hdm-card'); + if (!card) return; + try { + const data = await API.get('/movies/hund-des-monats'); + const [year, month] = data.monat.split('-'); + const monthName = new Intl.DateTimeFormat('de-DE', { month: 'long' }) + .format(new Date(+year, +month - 1, 1)); + const top = data.top?.[0]; + const winnerLine = top + ? `🥇 ${_esc(top.name)}${top.rasse ? ` · ${_esc(top.rasse)}` : ''}` + : 'Noch keine Stimmen'; + const metaLine = top + ? `${top.stimmen} Stimme${top.stimmen !== 1 ? 'n' : ''}` + : 'Sei der Erste!'; + + card.innerHTML = ` +
+
🏆
+
+
Hund des Monats · ${_esc(monthName)}
+
${winnerLine}
+
${metaLine}
+
+
${UI.icon('arrow-right')}
+
`; + + document.getElementById('forum-hdm-tile')?.addEventListener('click', () => _openHdmModal(data)); + } catch { + // Kachel bleibt leer bei Fehler + } + } + + async function _openHdmModal(data) { + // Immer frische Daten laden + try { data = await API.get('/movies/hund-des-monats'); } catch { /* nutze gecachte */ } + + const [year, month] = data.monat.split('-'); + const monthName = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' }) + .format(new Date(+year, +month - 1, 1)); + + let voteSection = ''; + if (_appState.user && _appState.dogs?.length > 0) { + const cards = _appState.dogs.map(dog => { + const isVoted = data.user_vote === dog.id; + const av = dog.foto_url + ? `${_esc(dog.name)}` + : `${_esc(dog.name.charAt(0).toUpperCase())}`; + return ` +
+
${av}
+
${_esc(dog.name)}
+ ${dog.rasse ? `
${_esc(dog.rasse)}
` : ''} + +
`; + }).join(''); + voteSection = ` +
+

Für welchen deiner Hunde möchtest du abstimmen?

+
${cards}
+
`; + } else if (!_appState.user) { + voteSection = ` +
+

+ Anmelden + um für deinen Hund abzustimmen. +

+
`; + } + + const topList = data.top?.length + ? data.top.slice(0, 5).map((dog, i) => { + const medal = ['🥇','🥈','🥉','4️⃣','5️⃣'][i]; + const av = dog.foto_url + ? `${_esc(dog.name)}` + : `${_esc(dog.name.charAt(0).toUpperCase())}`; + const vorname = dog.besitzer_name ? _esc(dog.besitzer_name.split(' ')[0]) : ''; + return ` +
+ ${medal} +
${av}
+
+
${_esc(dog.name)}
+ ${dog.rasse ? `
${_esc(dog.rasse)}
` : ''} + ${vorname ? `
von ${vorname}
` : ''} +
+
${dog.stimmen} ${UI.icon('star')}
+
`; + }).join('') + : `

Noch keine Stimmen diesen Monat. Sei der Erste!

`; + + const body = ` +
+
🏆
+

Hund des Monats

+
${_esc(monthName)}
+
+ ${voteSection} +
+

Top 5 diesen Monat

+
${topList}
+
`; + + UI.modal.open({ title: '🏆 Hund des Monats', body, + footer: `` }); + + document.getElementById('hdm-login-link')?.addEventListener('click', e => { + e.preventDefault(); UI.modal.close(); App.navigate('settings'); + }); + + document.querySelectorAll('.hdm-vote-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const dogId = parseInt(btn.dataset.dogId); + await UI.asyncButton(btn, async () => { + try { + await API.post('/movies/hund-des-monats/vote', { dog_id: dogId }); + UI.toast.success('Stimme abgegeben!'); + UI.modal.close(); + _loadHdmCard(); + } catch (err) { + UI.toast.error(err.message || 'Fehler beim Abstimmen.'); + } + }); + }); + }); + } + // ---------------------------------------------------------- // Threads laden // ---------------------------------------------------------- diff --git a/backend/static/js/pages/movies.js b/backend/static/js/pages/movies.js index 552928d..a36cf73 100644 --- a/backend/static/js/pages/movies.js +++ b/backend/static/js/pages/movies.js @@ -15,6 +15,7 @@ window.Page_movies = (() => { let _filter = 'alle'; let _typ = 'alle'; // alle | film | serie | doku let _sort = 'default'; // default | titel | jahr_desc | jahr_asc | imdb | bewertung + let _search = ''; // ---------------------------------------------------------- // INIT @@ -41,7 +42,6 @@ window.Page_movies = (() => {
-
`; @@ -66,7 +66,6 @@ window.Page_movies = (() => { if (_activeTab === 'filme') await _renderFilme(content); if (_activeTab === 'promis') _renderPromis(content); - if (_activeTab === 'hdm') await _renderHundDesMonats(content); } // ---------------------------------------------------------- @@ -86,6 +85,11 @@ window.Page_movies = (() => { content.innerHTML = `
+
+ + +
@@ -135,6 +139,11 @@ window.Page_movies = (() => { }); }); + content.querySelector('#movies-search')?.addEventListener('input', e => { + _search = e.target.value.trim().toLowerCase(); + _renderMovieGrid(content.querySelector('#movie-grid')); + }); + content.querySelector('#movies-sort')?.addEventListener('change', async e => { _sort = e.target.value; const grid = content.querySelector('#movie-grid'); @@ -153,6 +162,14 @@ window.Page_movies = (() => { if (_filter === 'stirbt') list = list.filter(f => f.stirbt_der_hund); if (_filter === 'ueberlebt') list = list.filter(f => !f.stirbt_der_hund); if (_filter === 'top') list = list.filter(f => (f.imdb_rating || 0) >= 7.5 || f.bewertung_avg >= 4.0); + if (_search) { + list = list.filter(f => + (f.titel || '').toLowerCase().includes(_search) || + (f.hund_rasse || '').toLowerCase().includes(_search) || + (f.genre || '').toLowerCase().includes(_search) || + (f.beschreibung || '').toLowerCase().includes(_search) + ); + } const countEl = document.getElementById('movies-count'); if (countEl) countEl.textContent = `${list.length} Einträge`; diff --git a/backend/static/sw.js b/backend/static/sw.js index 4ff9fe1..a727200 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-v591'; +const CACHE_VERSION = 'by-v594'; 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