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.rasse)}
` - : ``} + ? `${_esc(dog.rasse)}
` + : ``} + + ${(dog.hdm_wins?.length) ? ` ++ Anmelden + um für deinen Hund abzustimmen. +
+Noch keine Stimmen diesen Monat. Sei der Erste!
`; + + const body = ` +