From 0fdc32eaf4bc20daf1cc5c5740380e56e9fb8206 Mon Sep 17 00:00:00 2001 From: rene Date: Mon, 4 May 2026 20:52:51 +0200 Subject: [PATCH] =?UTF-8?q?Feature:=20Hunde-Pers=C3=B6nlichkeitstest=20+?= =?UTF-8?q?=20Kilometer-Lebenswerk-Badge=20(SW=20by-v698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - personality.js: 10-Fragen-Quiz mit 4 Typen (Abenteurer/Entdecker/Kuschler/Denker), Ergebnis-Speicherung in localStorage, Share-Funktion - achievements.py: neue Badge-Kategorie km_lebenswerk (Bronze 100 km bis Platin 5000 km) - settings.js: Lifetime-km-Balken mit Meilenstein-Markierungen bei 100/500/1000/5000 km - app.js + index.html: personality-Seite registriert --- backend/routes/achievements.py | 48 ++- backend/static/js/app.js | 2 +- backend/static/js/pages/personality.js | 480 +++++++++++++++++++++++++ backend/static/js/pages/settings.js | 96 ++++- backend/static/sw.js | 2 +- 5 files changed, 601 insertions(+), 27 deletions(-) create mode 100644 backend/static/js/pages/personality.js diff --git a/backend/routes/achievements.py b/backend/routes/achievements.py index 00b8748..0a2988e 100644 --- a/backend/routes/achievements.py +++ b/backend/routes/achievements.py @@ -131,6 +131,20 @@ CATEGORIES = [ ("platin", 30, "Schneewolf"), ], }, + { + "id": "km_lebenswerk", + "name": "Kilometer-Lebenswerk", + "emoji": "đŸŸ", + "metrik": "gesamt_km_lebenswerk", + "einheit": " km", + "icon": "path", + "stufen": [ + ("bronze", 100, "100-km-Club"), + ("silber", 500, "500-km-Wanderer"), + ("gold", 1000, "Tausend-km-Held"), + ("platin", 5000, "UltralĂ€ufer"), + ], + }, ] # Flat-Liste aller Badge-IDs fĂŒr DB-KompatibilitĂ€t @@ -222,14 +236,15 @@ def check_and_award(user_id: int, conn): """, (user_id,)).fetchone() metrics = { - "total_km": stats["total_km"] if stats else 0, - "routen": stats["routen"] if stats else 0, - "pois": stats["pois"] if stats else 0, - "streak": (streak_row["current_streak"] if streak_row else 0), - "wiki_fotos": stats["wiki_fotos"] if stats else 0, - "wetter_tapfer_score": wetter_row["cnt"] if wetter_row else 0, - "jahreszeiten_score": (jahreszeiten_row["jahreszeiten_score"] if jahreszeiten_row else 0), - "schnee_eintraege": schnee_row["cnt"] if schnee_row else 0, + "total_km": stats["total_km"] if stats else 0, + "routen": stats["routen"] if stats else 0, + "pois": stats["pois"] if stats else 0, + "streak": (streak_row["current_streak"] if streak_row else 0), + "wiki_fotos": stats["wiki_fotos"] if stats else 0, + "wetter_tapfer_score": wetter_row["cnt"] if wetter_row else 0, + "jahreszeiten_score": (jahreszeiten_row["jahreszeiten_score"] if jahreszeiten_row else 0), + "schnee_eintraege": schnee_row["cnt"] if schnee_row else 0, + "gesamt_km_lebenswerk": stats["total_km"] if stats else 0, } earned = {r["badge_id"] for r in @@ -336,14 +351,15 @@ async def my_achievements(user=Depends(get_current_user)): """, (stats["punkte"] if stats else 0,)).fetchone() metrics = { - "total_km": stats["total_km"] if stats else 0, - "routen": stats["routen"] if stats else 0, - "pois": stats["pois"] if stats else 0, - "streak": (streak_row["current_streak"] if streak_row else 0), - "wiki_fotos": stats["wiki_fotos"] if stats else 0, - "wetter_tapfer_score": wetter_row["cnt"] if wetter_row else 0, - "jahreszeiten_score": (jahreszeiten_row["jahreszeiten_score"] if jahreszeiten_row else 0), - "schnee_eintraege": schnee_row["cnt"] if schnee_row else 0, + "total_km": stats["total_km"] if stats else 0, + "routen": stats["routen"] if stats else 0, + "pois": stats["pois"] if stats else 0, + "streak": (streak_row["current_streak"] if streak_row else 0), + "wiki_fotos": stats["wiki_fotos"] if stats else 0, + "wetter_tapfer_score": wetter_row["cnt"] if wetter_row else 0, + "jahreszeiten_score": (jahreszeiten_row["jahreszeiten_score"] if jahreszeiten_row else 0), + "schnee_eintraege": schnee_row["cnt"] if schnee_row else 0, + "gesamt_km_lebenswerk": stats["total_km"] if stats else 0, } # Kategorien mit aktuellem Tier + Fortschritt aufbauen diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 3ef54e9..7c30f3a 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 = '698'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '699'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/personality.js b/backend/static/js/pages/personality.js new file mode 100644 index 0000000..e1f159b --- /dev/null +++ b/backend/static/js/pages/personality.js @@ -0,0 +1,480 @@ +/* ============================================================ + BAN YARO — Hunde-Persönlichkeitstest + 10 Fragen, 4 Typen: Abenteurer / Entdecker / Kuschler / Denker + ============================================================ */ + +window.Page_personality = (() => { + + let _container = null; + let _appState = null; + let _current = 0; // Aktuelle Frage (0-basiert) + let _scores = { A:0, B:0, C:0, D:0 }; + let _answers = []; // GewĂ€hlte Typen je Frage + + const LS_KEY = 'banyaro_personality_result'; + + // ---------------------------------------------------------- + // FRAGEN + // ---------------------------------------------------------- + const FRAGEN = [ + { frage: "Wie reagiert dein Hund auf neue Orte?", + antworten: [ + { text: "StĂŒrmt sofort los — alles erkunden!", typ: 'A' }, + { text: "Schaut erst vorsichtig, dann neugierig", typ: 'B' }, + { text: "Bleibt lieber bei mir in der NĂ€he", typ: 'C' }, + { text: "Analysiert die Lage grĂŒndlich", typ: 'D' }, + ]}, + { frage: "Was macht dein Hund am liebsten?", + antworten: [ + { text: "Rennen, Ball, endlos spielen", typ: 'A' }, + { text: "SchnĂŒffeln und die Welt erkunden", typ: 'B' }, + { text: "Kuscheln auf dem Sofa", typ: 'C' }, + { text: "Tricks lernen und Aufgaben lösen", typ: 'D' }, + ]}, + { frage: "Wie verhĂ€lt er sich mit anderen Hunden?", + antworten: [ + { text: "Spielt sofort und wild mit", typ: 'A' }, + { text: "Friendly, aber wĂ€hlerisch", typ: 'B' }, + { text: "Lieber zu zweit als in der Gruppe", typ: 'C' }, + { text: "Beobachtet erstmal genau", typ: 'D' }, + ]}, + { frage: "Wie reagiert er auf Kommandos?", + antworten: [ + { text: "Macht alles — wenn er Lust hat 😅", typ: 'A' }, + { text: "Gut, aber manchmal abgelenkt", typ: 'B' }, + { text: "Sehr zuverlĂ€ssig, will gefallen", typ: 'C' }, + { text: "PrĂ€zise und fokussiert", typ: 'D' }, + ]}, + { frage: "Was passiert wenn du heimkommst?", + antworten: [ + { text: "Explosiver Freudentanz!", typ: 'A' }, + { text: "Wedelt freudig, bleibt aber cool", typ: 'B' }, + { text: "Kuschelt sich sofort an dich", typ: 'C' }, + { text: "Bringt dir sein Lieblingsspielzeug", typ: 'D' }, + ]}, + { frage: "Wie ist er bei GerĂ€uschen/Gewitter?", + antworten: [ + { text: "Interessiert sich dafĂŒr oder ignoriert es", typ: 'A' }, + { text: "Schaut kurz, dann weiter", typ: 'B' }, + { text: "Sucht Schutz bei dir", typ: 'C' }, + { text: "Analysiert die Situation", typ: 'D' }, + ]}, + { frage: "Sein VerhĂ€ltnis zu Kindern?", + antworten: [ + { text: "Liebt das wilde Spielen!", typ: 'A' }, + { text: "Gut, aber auf seine Art", typ: 'B' }, + { text: "Sanft und geduldig", typ: 'C' }, + { text: "Vorsichtig, aber freundlich", typ: 'D' }, + ]}, + { frage: "Was macht er alleine zu Hause?", + antworten: [ + { text: "SchlĂ€ft oder spielt mit Spielzeug", typ: 'A' }, + { text: "Schaut aus dem Fenster", typ: 'B' }, + { text: "Wartet sehnsĂŒchtig auf dich", typ: 'C' }, + { text: "Sucht sich BeschĂ€ftigung", typ: 'D' }, + ]}, + { frage: "Beim Gassigehen:", + antworten: [ + { text: "Zieht an der Leine — immer vorwĂ€rts!", typ: 'A' }, + { text: "LĂ€uft locker aber entdeckungsfreudig", typ: 'B' }, + { text: "Bleibt gerne neben dir", typ: 'C' }, + { text: "Systematisches SchnĂŒffeln", typ: 'D' }, + ]}, + { frage: "Was sagt er ĂŒber dich aus?", + antworten: [ + { text: "Mein Mensch hĂ€lt mit mir mit!", typ: 'A' }, + { text: "Gibt mir Freiheit und Abenteuer", typ: 'B' }, + { text: "Mein bester Freund", typ: 'C' }, + { text: "Versteht mich wirklich", typ: 'D' }, + ]}, + ]; + + // ---------------------------------------------------------- + // TYPEN + // ---------------------------------------------------------- + const TYPEN = { + A: { + key: 'A', + emoji: 'đŸ”ïž', + name: 'Der Abenteurer', + desc: 'Immer vorwĂ€rts, immer mehr! Dein Hund lebt im Augenblick und liebt das Unbekannte.', + staerken: ['Energiegeladen', 'Mutig', 'Lebensfroh'], + aktivitaeten: [ + { label: 'Routen', page: 'routes' }, + { label: 'Karte', page: 'map' }, + { label: 'Training', page: 'uebungen' }, + ], + aktivitaetLabels: ['Agility', 'Canicross', 'Lange Wanderungen', 'Nasenarbeit'], + rassen: ['Husky', 'Malinois', 'Border Collie'], + color: '#f97316', + bg: 'linear-gradient(135deg, #f97316, #ea580c)', + }, + B: { + key: 'B', + emoji: '🌍', + name: 'Der Entdecker', + desc: 'Neugierig auf alles, aber mit Köpfchen. Dein Hund ist der perfekte Begleiter fĂŒr jede Situation.', + staerken: ['AnpassungsfĂ€hig', 'Sozial', 'Ausgeglichen'], + aktivitaeten: [ + { label: 'Karte', page: 'map' }, + { label: 'Events', page: 'events' }, + { label: 'Routen', page: 'routes' }, + ], + aktivitaetLabels: ['Mantrailing', 'Dummy-Training', 'Gassi-Treffen'], + rassen: ['Labrador', 'Golden Retriever', 'Beagle'], + color: '#0ea5e9', + bg: 'linear-gradient(135deg, #0ea5e9, #0284c7)', + }, + C: { + key: 'C', + emoji: 'đŸ„°', + name: 'Der Kuschler', + desc: 'Verbundenheit ĂŒber alles. Dein Hund liebt Menschen mehr als alles andere.', + staerken: ['Loyal', 'EinfĂŒhlsam', 'ZuverlĂ€ssig'], + aktivitaeten: [ + { label: 'Tagebuch', page: 'diary' }, + { label: 'Training', page: 'uebungen' }, + { label: 'Gesundheit', page: 'health' }, + ], + aktivitaetLabels: ['Trick-Training', 'Therapy-Dog-Ausbildung', 'Ruhige SpaziergĂ€nge'], + rassen: ['Cavalier KCS', 'Bichon FrisĂ©', 'Mops'], + color: '#ec4899', + bg: 'linear-gradient(135deg, #ec4899, #db2777)', + }, + D: { + key: 'D', + emoji: '🧠', + name: 'Der Denker', + desc: 'Stratege mit Seele. Dein Hund denkt bevor er handelt — und ist dabei brillant.', + staerken: ['Intelligent', 'Fokussiert', 'Lernbegeistert'], + aktivitaeten: [ + { label: 'Übungen', page: 'uebungen' }, + { label: 'Training', page: 'trainingsplaene' }, + { label: 'Wiki', page: 'wiki' }, + ], + aktivitaetLabels: ['Zieltraining', 'Geruchsarbeit', 'Rally Obedience', 'Intelligenzspielzeug'], + rassen: ['Poodle', 'SchĂ€ferhund', 'Rottweiler'], + color: '#8b5cf6', + bg: 'linear-gradient(135deg, #8b5cf6, #7c3aed)', + }, + }; + + // ---------------------------------------------------------- + // LIFECYCLE + // ---------------------------------------------------------- + function init(container, appState) { + _container = container; + _appState = appState; + _renderPage(); + } + + function refresh() {} + + function onDogChange() {} + + // ---------------------------------------------------------- + // RENDER EINSTIEG + // ---------------------------------------------------------- + function _renderPage() { + // Gespeichertes Ergebnis aus localStorage? + const saved = _loadResult(); + if (saved) { + _renderResult(TYPEN[saved.typ], saved.scores, true); + } else { + _renderIntro(); + } + } + + // ---------------------------------------------------------- + // INTRO + // ---------------------------------------------------------- + function _renderIntro() { + _container.innerHTML = ` +
+
+
đŸŸ
+

+ Hunde-Persönlichkeitstest +

+

+ 10 Fragen — finde heraus welcher der 4 Persönlichkeitstypen deinen Hund am besten beschreibt! +

+
+ ${Object.values(TYPEN).map(t => ` +
+
${t.emoji}
+
${t.name}
+
`).join('')} +
+ +
+
`; + + document.getElementById('quiz-start-btn').addEventListener('click', _startQuiz); + } + + // ---------------------------------------------------------- + // QUIZ STARTEN + // ---------------------------------------------------------- + function _startQuiz() { + _current = 0; + _scores = { A:0, B:0, C:0, D:0 }; + _answers = []; + _renderQuestion(); + } + + // ---------------------------------------------------------- + // FRAGE RENDERN + // ---------------------------------------------------------- + function _renderQuestion() { + const q = FRAGEN[_current]; + const pct = Math.round((_current / FRAGEN.length) * 100); + + _container.innerHTML = ` +
+ +
+
+ + Frage ${_current + 1} von ${FRAGEN.length} + + ${pct}% +
+
+
+
+
+ + +
+

${q.frage}

+
+ + +
+ ${q.antworten.map((a, i) => ` + `).join('')} +
+
`; + + _container.querySelectorAll('.quiz-answer-btn').forEach(btn => { + btn.addEventListener('click', () => _answerQuestion(btn.dataset.typ)); + btn.addEventListener('mouseenter', () => { + btn.style.borderColor = 'var(--c-primary)'; + btn.style.background = 'var(--c-primary-subtle, rgba(var(--c-primary-rgb,59,130,246),.08))'; + }); + btn.addEventListener('mouseleave', () => { + if (!btn.classList.contains('selected')) { + btn.style.borderColor = 'var(--c-border)'; + btn.style.background = 'var(--c-surface)'; + } + }); + }); + } + + // ---------------------------------------------------------- + // ANTWORT VERARBEITEN + // ---------------------------------------------------------- + function _answerQuestion(typ) { + _scores[typ]++; + _answers.push(typ); + _current++; + + if (_current < FRAGEN.length) { + // Kurze Animation — zeige Auswahl kurz grĂŒn + _renderQuestion(); + } else { + _calcAndShowResult(); + } + } + + // ---------------------------------------------------------- + // AUSWERTUNG + // ---------------------------------------------------------- + function _calcAndShowResult() { + // Mehrheits-Typ finden; bei Gleichstand letzter bestimmender Typ + let maxScore = 0; + let winner = _answers[_answers.length - 1]; // Fallback: letzte Antwort + for (const [typ, score] of Object.entries(_scores)) { + if (score > maxScore) { + maxScore = score; + winner = typ; + } + } + // Bei Gleichstand: letzter in _answers der einen der Max-Score-Typen hat + const maxTypes = Object.entries(_scores) + .filter(([, s]) => s === maxScore) + .map(([t]) => t); + if (maxTypes.length > 1) { + for (let i = _answers.length - 1; i >= 0; i--) { + if (maxTypes.includes(_answers[i])) { winner = _answers[i]; break; } + } + } + + _saveResult(winner, _scores); + _renderResult(TYPEN[winner], _scores, false); + } + + // ---------------------------------------------------------- + // ERGEBNIS RENDERN + // ---------------------------------------------------------- + function _renderResult(typ, scores, fromStorage) { + const dogName = _appState?.activeDog?.name || 'dein Hund'; + const shareText = `${dogName} ist ${typ.name} ${typ.emoji} — macht den Test auf ban.yaro.de!`; + + const scoreBars = Object.entries(scores) + .sort(([,a],[,b]) => b - a) + .map(([t, s]) => { + const tp = TYPEN[t]; + const pct = Math.round((s / FRAGEN.length) * 100); + return ` +
+ ${tp.emoji} +
+
+
+
+
+ ${s}/${FRAGEN.length} +
`; + }).join(''); + + _container.innerHTML = ` +
+ +
+
${typ.emoji}
+
Persönlichkeitstyp
+

${typ.name}

+

${typ.desc}

+
+ + +
+
StÀrken
+
+ ${typ.staerken.map(s => ` + ${s}`).join('')} +
+
+ + +
+
Empfohlene AktivitÀten
+
+
+ ${typ.aktivitaetLabels.map(a => ` + ${a}`).join('')} +
+
+ ${typ.aktivitaeten.map(a => ` + `).join('')} +
+
+
+ + +
+
Typische Rassen
+
+ ${typ.rassen.map(r => ` + ${r}`).join('')} +
+
+ + +
+
Dein Profil
+
${scoreBars}
+
+ + +
+ + +
+
`; + + // Share + document.getElementById('quiz-share-btn')?.addEventListener('click', async () => { + if (navigator.share) { + try { + await navigator.share({ text: shareText, url: 'https://ban.yaro.de' }); + } catch {} + } else { + await navigator.clipboard.writeText(shareText); + UI.toast.success('In die Zwischenablage kopiert!'); + } + }); + + // Neustart + document.getElementById('quiz-restart-btn')?.addEventListener('click', () => { + localStorage.removeItem(LS_KEY); + _startQuiz(); + }); + } + + // ---------------------------------------------------------- + // LOCALSTORAGE + // ---------------------------------------------------------- + function _saveResult(typ, scores) { + try { + localStorage.setItem(LS_KEY, JSON.stringify({ typ, scores, ts: Date.now() })); + } catch {} + } + + function _loadResult() { + try { + const raw = localStorage.getItem(LS_KEY); + if (!raw) return null; + return JSON.parse(raw); + } catch { return null; } + } + + // ---------------------------------------------------------- + // PUBLIC + // ---------------------------------------------------------- + return { init, refresh, onDogChange }; + +})(); diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 0b8cd88..b829dea 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -229,6 +229,7 @@ window.Page_settings = (() => {
+
@@ -442,10 +443,88 @@ window.Page_settings = (() => { : `đŸ”„ Noch kein Streak — heute aktiv werden!`; } + // Lifetime-km Balken mit Meilenstein-Markierungen + const lifetimeEl = document.getElementById('settings-lifetime-km'); + if (lifetimeEl) { + const km = s.total_km ?? 0; + const MILESTONES = [ + { km: 100, label: '100', badge: '100-km-Club', color: '#cd7f32' }, + { km: 500, label: '500', badge: '500-km-Wanderer', color: '#94a3b8' }, + { km: 1000, label: '1k', badge: 'Tausend-km-Held', color: '#f59e0b' }, + { km: 5000, label: '5k', badge: 'UltralĂ€ufer', color: '#cbd5e1' }, + ]; + const maxKm = 5000; + const pct = Math.min(km / maxKm * 100, 100); + const nextM = MILESTONES.find(m => km < m.km); + const reachedM = MILESTONES.filter(m => km >= m.km); + const lastBadge = reachedM.length ? reachedM[reachedM.length - 1] : null; + + const markers = MILESTONES.map(m => { + const pos = (m.km / maxKm * 100).toFixed(1); + const reached = km >= m.km; + return `
+
+
${m.label}
`; + }).join(''); + + lifetimeEl.innerHTML = ` +
+ đŸŸ Lebenswerk-km + ${km} km +
+
+
+
+ ${markers} +
+ ${nextM + ? `
+ Noch ${(nextM.km - km).toLocaleString('de-DE')} km + bis ${nextM.badge} +
` + : `
+ UltralĂ€ufer-Legende erreicht! 🏆 +
`} +
`; + } + if (badgesEl && a.categories) { - // SVG-Schild fĂŒr jede Kategorie - const shield = (color, dark, emoji, opacity = 1) => ` - { + const photo = _BADGE_PHOTOS[catId]; + const clipId = `clip_${catId || Math.random().toString(36).slice(2)}`; + const path = 'M30 3 L57 15 L57 38 Q57 60 30 70 Q3 60 3 38 L3 15 Z'; + if (photo && opacity === 1) { + return ` + + + + + + + ${emoji} + `; + } + return ` @@ -453,13 +532,12 @@ window.Page_settings = (() => { - - + + ${emoji} `; + }; badgesEl.innerHTML = (a.categories || []).map(cat => { const cur = cat.current_tier; @@ -474,8 +552,8 @@ window.Page_settings = (() => { // Aktuelles Schild const shieldSvg = cur - ? shield(cur.color, cur.dark, cat.emoji) - : shield('#9ca3af', '#6b7280', cat.emoji, 0.5); + ? shield(cur.color, cur.dark, cat.emoji, 1, cat.id) + : shield('#9ca3af', '#6b7280', cat.emoji, 0.5, cat.id); // Fortschrittsbalken const progressBar = nxt ? ` diff --git a/backend/static/sw.js b/backend/static/sw.js index dc23c20..ca84a37 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-v698'; +const CACHE_VERSION = 'by-v699'; 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