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

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