Feature: Hunde-Persönlichkeitstest + Kilometer-Lebenswerk-Badge (SW by-v698)

- 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
This commit is contained in:
rene 2026-05-04 20:52:51 +02:00
parent a4e97348ed
commit 0fdc32eaf4
5 changed files with 601 additions and 27 deletions

View file

@ -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

View file

@ -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';

View file

@ -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 = `
<div class="page-body page-container">
<div style="text-align:center;padding:var(--space-6) var(--space-4) var(--space-4)">
<div style="font-size:3.5rem;margin-bottom:var(--space-3)">🐾</div>
<h1 style="font-size:var(--text-xl);font-weight:800;margin-bottom:var(--space-2)">
Hunde-Persönlichkeitstest
</h1>
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);max-width:320px;margin:0 auto var(--space-6)">
10 Fragen finde heraus welcher der 4 Persönlichkeitstypen deinen Hund am besten beschreibt!
</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);max-width:340px;margin:0 auto var(--space-6)">
${Object.values(TYPEN).map(t => `
<div style="background:var(--c-surface);border:1px solid var(--c-border);border-radius:var(--radius-lg);
padding:var(--space-3);text-align:center">
<div style="font-size:1.8rem">${t.emoji}</div>
<div style="font-size:var(--text-xs);font-weight:600;margin-top:4px;color:${t.color}">${t.name}</div>
</div>`).join('')}
</div>
<button class="btn btn-primary" style="padding:14px 40px;font-size:1rem;font-weight:700;border-radius:999px"
id="quiz-start-btn">Quiz starten</button>
</div>
</div>`;
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 = `
<div class="page-body page-container">
<!-- Fortschritt -->
<div style="padding:var(--space-4) var(--space-4) 0">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">
Frage ${_current + 1} von ${FRAGEN.length}
</span>
<span style="font-size:var(--text-xs);font-weight:600;color:var(--c-primary)">${pct}%</span>
</div>
<div style="height:6px;background:var(--c-border);border-radius:3px;overflow:hidden">
<div style="height:100%;width:${pct}%;background:var(--c-primary);border-radius:3px;transition:width .4s"></div>
</div>
</div>
<!-- Frage -->
<div style="padding:var(--space-5) var(--space-4) var(--space-3)">
<h2 style="font-size:var(--text-lg);font-weight:700;line-height:1.4;margin:0">${q.frage}</h2>
</div>
<!-- Antworten -->
<div style="padding:0 var(--space-4) var(--space-6);display:flex;flex-direction:column;gap:var(--space-3)">
${q.antworten.map((a, i) => `
<button class="quiz-answer-btn" data-typ="${a.typ}"
style="text-align:left;padding:var(--space-4);background:var(--c-surface);
border:2px solid var(--c-border);border-radius:var(--radius-lg);
font-size:var(--text-sm);cursor:pointer;transition:all .15s;
color:var(--c-text);line-height:1.4;
display:flex;align-items:center;gap:var(--space-3)">
<span style="width:28px;height:28px;border-radius:50%;background:var(--c-bg);
border:1.5px solid var(--c-border);display:flex;align-items:center;justify-content:center;
font-size:11px;font-weight:700;color:var(--c-text-secondary);flex-shrink:0">
${String.fromCharCode(65 + i)}
</span>
<span>${a.text}</span>
</button>`).join('')}
</div>
</div>`;
_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 `
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<span style="font-size:1rem;width:24px;text-align:center">${tp.emoji}</span>
<div style="flex:1">
<div style="height:8px;background:var(--c-border);border-radius:4px;overflow:hidden">
<div style="height:100%;width:${pct}%;background:${tp.color};border-radius:4px;transition:width .6s"></div>
</div>
</div>
<span style="font-size:var(--text-xs);color:var(--c-text-secondary);width:28px;text-align:right">${s}/${FRAGEN.length}</span>
</div>`;
}).join('');
_container.innerHTML = `
<div class="page-body page-container">
<!-- Hero -->
<div style="background:${typ.bg};padding:var(--space-8) var(--space-4) var(--space-6);
text-align:center;color:#fff;border-radius:0 0 var(--radius-xl) var(--radius-xl);
margin-bottom:var(--space-5)">
<div style="font-size:4rem;margin-bottom:var(--space-2)">${typ.emoji}</div>
<div style="font-size:var(--text-xs);font-weight:600;opacity:.8;text-transform:uppercase;letter-spacing:.1em;
margin-bottom:var(--space-1)">Persönlichkeitstyp</div>
<h1 style="font-size:var(--text-2xl);font-weight:800;margin:0 0 var(--space-3)">${typ.name}</h1>
<p style="font-size:var(--text-sm);opacity:.9;max-width:300px;margin:0 auto;line-height:1.5">${typ.desc}</p>
</div>
<!-- Stärken -->
<div class="card" style="margin:0 var(--space-4) var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);font-size:var(--text-xs);font-weight:600;
color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:0.05em;
border-bottom:1px solid var(--c-border)">Stärken</div>
<div style="padding:var(--space-3) var(--space-4);display:flex;flex-wrap:wrap;gap:8px">
${typ.staerken.map(s => `
<span style="background:${typ.color}22;color:${typ.color};padding:5px 14px;
border-radius:999px;font-size:var(--text-xs);font-weight:600">${s}</span>`).join('')}
</div>
</div>
<!-- Empfohlene Aktivitäten -->
<div class="card" style="margin:0 var(--space-4) var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);font-size:var(--text-xs);font-weight:600;
color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:0.05em;
border-bottom:1px solid var(--c-border)">Empfohlene Aktivitäten</div>
<div style="padding:var(--space-3) var(--space-4)">
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:var(--space-3)">
${typ.aktivitaetLabels.map(a => `
<span style="background:var(--c-surface);border:1px solid var(--c-border);
padding:5px 14px;border-radius:999px;font-size:var(--text-xs)">${a}</span>`).join('')}
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
${typ.aktivitaeten.map(a => `
<button class="btn btn-secondary" style="font-size:var(--text-xs);padding:6px 14px;border-radius:999px"
onclick="App.navigate('${a.page}')">${a.label} </button>`).join('')}
</div>
</div>
</div>
<!-- Bekannte Rassen -->
<div class="card" style="margin:0 var(--space-4) var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);font-size:var(--text-xs);font-weight:600;
color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:0.05em;
border-bottom:1px solid var(--c-border)">Typische Rassen</div>
<div style="padding:var(--space-3) var(--space-4);display:flex;flex-wrap:wrap;gap:8px">
${typ.rassen.map(r => `
<span style="background:var(--c-bg);border:1px solid var(--c-border);
padding:5px 14px;border-radius:999px;font-size:var(--text-xs)">${r}</span>`).join('')}
</div>
</div>
<!-- Punkteverteilung -->
<div class="card" style="margin:0 var(--space-4) var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);font-size:var(--text-xs);font-weight:600;
color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:0.05em;
border-bottom:1px solid var(--c-border)">Dein Profil</div>
<div style="padding:var(--space-4)">${scoreBars}</div>
</div>
<!-- Teilen + Nochmal -->
<div style="padding:0 var(--space-4) var(--space-8);display:flex;flex-direction:column;gap:var(--space-3)">
<button class="btn btn-secondary" id="quiz-share-btn"
style="padding:14px;font-size:var(--text-sm);border-radius:var(--radius-lg);
display:flex;align-items:center;justify-content:center;gap:8px">
<svg style="width:18px;height:18px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/>
<polyline points="16 6 12 2 8 6"/>
<line x1="12" y1="2" x2="12" y2="15"/>
</svg>
Ergebnis teilen
</button>
<button class="btn btn-primary" id="quiz-restart-btn"
style="padding:14px;font-size:var(--text-sm);border-radius:var(--radius-lg)">
Nochmal machen
</button>
</div>
</div>`;
// 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 };
})();

View file

@ -229,6 +229,7 @@ window.Page_settings = (() => {
</div>
<div id="settings-streak" style="display:flex;align-items:center;gap:8px;
padding:0 var(--space-4) var(--space-3);flex-wrap:wrap"></div>
<div id="settings-lifetime-km" style="border-top:1px solid var(--c-border)"></div>
</div>
<!-- Züchter-Profil Slot -->
@ -442,10 +443,88 @@ window.Page_settings = (() => {
: `<span style="color:var(--c-text-muted);font-size:var(--text-sm)">🔥 Noch kein Streak — heute aktiv werden!</span>`;
}
// 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 `<div title="${m.badge}" style="position:absolute;left:${pos}%;top:-4px;transform:translateX(-50%);
width:12px;height:12px;border-radius:50%;border:2px solid ${m.color};
background:${reached ? m.color : 'var(--c-bg)'};z-index:2">
</div>
<div style="position:absolute;left:${pos}%;top:12px;transform:translateX(-50%);
font-size:9px;color:${reached ? m.color : 'var(--c-text-muted)'};font-weight:600;
white-space:nowrap">${m.label}</div>`;
}).join('');
lifetimeEl.innerHTML = `
<div style="padding:var(--space-3) var(--space-4) 0;
display:flex;justify-content:space-between;align-items:center">
<span style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-secondary);
text-transform:uppercase;letter-spacing:.05em">🐾 Lebenswerk-km</span>
<span style="font-size:var(--text-sm);font-weight:700;color:var(--c-primary)">${km} km</span>
</div>
<div style="padding:var(--space-3) var(--space-4) var(--space-4)">
<div style="position:relative;height:8px;background:var(--c-border);border-radius:4px;
overflow:visible;margin-bottom:22px">
<div style="position:absolute;left:0;top:0;height:100%;width:${pct}%;
background:linear-gradient(90deg,#10b981,#0ea5e9);
border-radius:4px;z-index:1;transition:width .6s"></div>
${markers}
</div>
${nextM
? `<div style="font-size:11px;color:var(--c-text-muted)">
Noch <strong>${(nextM.km - km).toLocaleString('de-DE')}&nbsp;km</strong>
bis <span style="color:${nextM.color};font-weight:600">${nextM.badge}</span>
</div>`
: `<div style="font-size:11px;color:var(--c-primary);font-weight:600">
Ultraläufer-Legende erreicht! 🏆
</div>`}
</div>`;
}
if (badgesEl && a.categories) {
// SVG-Schild für jede Kategorie
const shield = (color, dark, emoji, opacity = 1) => `
<svg viewBox="0 0 60 72" xmlns="http://www.w3.org/2000/svg"
// Foto-Hintergründe für bestimmte Badge-Kategorien
const _BADGE_PHOTOS = {
'schnee_held': '/img/banyaro/winter_schnee.webp',
'jahreszeiten': '/img/banyaro/herbst_bach.webp',
'wetter_tapfer': '/img/banyaro/fruehling_playdate.webp',
};
// SVG-Schild für jede Kategorie (mit optionalem Foto-Hintergrund)
const shield = (color, dark, emoji, opacity = 1, catId = '') => {
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 `<svg viewBox="0 0 60 72" xmlns="http://www.w3.org/2000/svg"
style="width:56px;height:56px;filter:drop-shadow(0 2px 8px rgba(0,0,0,.4))">
<defs>
<clipPath id="${clipId}"><path d="${path}"/></clipPath>
</defs>
<image href="${photo}" x="0" y="0" width="60" height="72"
clip-path="url(#${clipId})" preserveAspectRatio="xMidYMid slice"/>
<path d="${path}" fill="rgba(0,0,0,0.28)"/>
<path d="${path}" fill="none" stroke="rgba(255,255,255,.4)" stroke-width="1.5"/>
<text x="30" y="43" text-anchor="middle" dominant-baseline="middle"
font-size="22" style="user-select:none">${emoji}</text>
</svg>`;
}
return `<svg viewBox="0 0 60 72" xmlns="http://www.w3.org/2000/svg"
style="width:56px;height:56px;filter:drop-shadow(0 2px 6px ${color}66)">
<defs>
<linearGradient id="g_${color.replace('#','')}" x1="0" y1="0" x2="1" y2="1">
@ -453,13 +532,12 @@ window.Page_settings = (() => {
<stop offset="100%" stop-color="${dark}"/>
</linearGradient>
</defs>
<path d="M30 3 L57 15 L57 38 Q57 60 30 70 Q3 60 3 38 L3 15 Z"
fill="url(#g_${color.replace('#','')})" opacity="${opacity}"/>
<path d="M30 3 L57 15 L57 38 Q57 60 30 70 Q3 60 3 38 L3 15 Z"
fill="none" stroke="rgba(255,255,255,.25)" stroke-width="1.5"/>
<path d="${path}" fill="url(#g_${color.replace('#','')})" opacity="${opacity}"/>
<path d="${path}" fill="none" stroke="rgba(255,255,255,.25)" stroke-width="1.5"/>
<text x="30" y="43" text-anchor="middle" dominant-baseline="middle"
font-size="22" style="user-select:none">${emoji}</text>
</svg>`;
};
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 ? `

View file

@ -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