Security: CSP gehärtet — unsafe-inline + unsafe-eval raus, SW by-v1100

Inline-Scripts extrahiert:
- boot-early.js: Theme + theme-color (synchron im <head>, VOR CSS)
- boot.js: Offline-Banner + Service-Worker-Registration + Update-Flow
- landing-init.js: Dark-mode + Scroll-Animationen + Live-Stats +
  Stay-In-App-Handler + Details-Toggle

Inline onclick-Handler in landing.html:
- 5× sessionStorage.setItem('by_stay_in_app','1') → data-stay-in-app
- 1× Details-Toggle → data-toggle-target + data-toggle-text-open
- JS-Handler in landing-init.js binden die data-Attribute

CSP-Header (main.py):
- script-src: 'unsafe-inline' und 'unsafe-eval' entfernt
- style-src 'unsafe-inline' bleibt (Inline-Styles bleiben für jetzt,
  zu viele Fundstellen)
- Umami bleibt whitelisted

SW STATIC_ASSETS erweitert um boot-early.js + boot.js.
make bump aktualisiert jetzt auch landing.html ?v= Anker.
Tests grün (19/19).
This commit is contained in:
rene 2026-05-27 06:23:47 +02:00
parent 15d319fbd5
commit 65cfa25e59
10 changed files with 267 additions and 226 deletions

View file

@ -287,7 +287,8 @@ bump:
sed -i.bak -E "s/const VER[[:space:]]*=[[:space:]]*'[0-9]+'/const VER = '$$NEW'/" backend/static/sw.js && rm -f backend/static/sw.js.bak; \
sed -i.bak -E "s/const APP_VER[[:space:]]*=[[:space:]]*'[0-9]+'/const APP_VER = '$$NEW'/" backend/static/js/app.js && rm -f backend/static/js/app.js.bak; \
sed -i.bak -E "s/\?v=[0-9]+/?v=$$NEW/g" backend/static/index.html && rm -f backend/static/index.html.bak; \
echo " ✓ APP_VER $$CUR → $$NEW (VERSION, sw.js, app.js, index.html aktualisiert)"
sed -i.bak -E "s/\?v=[0-9]+/?v=$$NEW/g" backend/static/landing.html && rm -f backend/static/landing.html.bak; \
echo " ✓ APP_VER $$CUR → $$NEW (VERSION, sw.js, app.js, index.html, landing.html aktualisiert)"
# ----------------------------------------------------------
# TEST — Smoke-Tests gegen isolierte Test-DB (kein Docker, kein DS)

View file

@ -1 +1 @@
1099
1100

View file

@ -110,8 +110,8 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://umami.motocamp.de; "
"style-src 'self' 'unsafe-inline'; "
"script-src 'self' https://umami.motocamp.de; " # ohne unsafe-inline/eval — alle Inline-Scripts extrahiert
"style-src 'self' 'unsafe-inline'; " # Inline-Styles bleiben (zu viele Fundstellen für jetzt)
"img-src 'self' data: blob: https:; "
"connect-src 'self' https:; "
"frame-ancestors 'none'; "

View file

@ -86,24 +86,12 @@
<title>Ban Yaro</title>
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
<script>
(function() {
var t = localStorage.getItem('by_theme');
var isDark = t === 'dark' || (t !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
var isAndroid = /android/i.test(navigator.userAgent);
if (t === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
if (t === 'light') document.documentElement.setAttribute('data-theme', 'light');
// Android: immer dunkel (Amber-Streifen nicht möglich transparent zu machen)
// iOS: black-translucent übernimmt das
var m = document.getElementById('meta-theme-color');
if (m) m.setAttribute('content', (isDark || isAndroid) ? '#0f1623' : '#C4843A');
})();
</script>
<script src="/js/boot-early.js?v=1100"></script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=1099">
<link rel="stylesheet" href="/css/layout.css?v=1099">
<link rel="stylesheet" href="/css/components.css?v=1099">
<link rel="stylesheet" href="/css/design-system.css?v=1100">
<link rel="stylesheet" href="/css/layout.css?v=1100">
<link rel="stylesheet" href="/css/components.css?v=1100">
</head>
<body>
@ -625,11 +613,11 @@
<div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=1099"></script>
<script src="/js/ui.js?v=1099"></script>
<script src="/js/app.js?v=1099"></script>
<script src="/js/worlds.js?v=1099"></script>
<script src="/js/offline-indicator.js?v=1099"></script>
<script src="/js/api.js?v=1100"></script>
<script src="/js/ui.js?v=1100"></script>
<script src="/js/app.js?v=1100"></script>
<script src="/js/worlds.js?v=1100"></script>
<script src="/js/offline-indicator.js?v=1100"></script>
<!-- Feature-Seiten werden lazy geladen -->
@ -637,130 +625,9 @@
<script defer src="/stats/script.js" data-website-id="d1b5fe13-0e6f-4461-a176-c5439cbbc27f" data-api-host="/stats"></script>
<!-- Offline-Banner Logik -->
<script>
(function() {
function _updateBanner() {
var banner = document.getElementById('offline-banner');
if (!banner) return;
banner.style.display = navigator.onLine ? 'none' : 'flex';
}
window.addEventListener('offline', function() {
_updateBanner();
// Einmaliger Hinweis pro Session: App im Vordergrund lassen
if (!sessionStorage.getItem('by_offline_hint_shown')) {
sessionStorage.setItem('by_offline_hint_shown', '1');
setTimeout(function() {
window.UI?.toast?.info(
'App im Vordergrund lassen — so bleiben Offline-Funktionen wie GPS und Datenspeicherung aktiv.',
8000
);
}, 800);
}
// Queue-Count abfragen
if (navigator.serviceWorker) {
navigator.serviceWorker.ready.then(function(reg) {
if (reg.active) reg.active.postMessage({ type: 'QUEUE_COUNT' });
});
}
});
window.addEventListener('online', function() {
_updateBanner();
var badge = document.getElementById('offline-queue-badge');
if (badge) badge.style.display = 'none';
// Queue abarbeiten
if (navigator.serviceWorker) {
navigator.serviceWorker.ready.then(function(reg) {
if (reg.active) reg.active.postMessage({ type: 'PROCESS_QUEUE' });
});
}
});
// Initial prüfen
_updateBanner();
})();
</script>
<!-- Service Worker -->
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' })
.then(reg => {
function _watchSW(sw) {
if (!sw) return;
sw.addEventListener('statechange', () => {
if (sw.state === 'activated') {
// Flag nur prüfen, nicht konsumieren — controllerchange konsumiert ihn
if (sessionStorage.getItem('by_skip_sw_reload')) return;
window.location.replace('/?_t=' + Date.now());
}
});
}
// Listener VOR update() registrieren — verhindert Race Condition
reg.addEventListener('updatefound', () => _watchSW(reg.installing));
// Falls SW bereits installiert (Seite wurde nach SW-Install neu geladen)
if (reg.installing) _watchSW(reg.installing);
reg.update();
})
.catch(err => console.warn('SW Registration failed:', err));
});
// Backup: erneut prüfen wenn App aus dem Hintergrund kommt
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
navigator.serviceWorker.getRegistration().then(reg => reg?.update());
}
});
// Backup: controllerchange (falls updatefound nicht feuert)
// NICHT registrieren wenn diese Seite selbst durch einen SW-Reload entstand (_t= im URL)
// — verhindert Dauerschleife wenn clients.claim() erst nach Seitenstart feuert
if (!window._BY_SW_RELOAD) {
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (sessionStorage.getItem('by_skip_sw_reload')) {
sessionStorage.removeItem('by_skip_sw_reload');
return;
}
window.location.replace('/?_t=' + Date.now());
});
}
navigator.serviceWorker.addEventListener('message', e => {
if (e.data?.type === 'QUEUE_PROCESSED') {
const { synced, failed, total } = e.data;
if (total === 0) return;
if (synced > 0 && window.UI?.toast) {
window.UI.toast.success(
synced === 1
? '1 offline gespeicherter Eintrag synchronisiert'
: `${synced} offline gespeicherte Einträge synchronisiert`
);
// Aktuelle Seite neu laden
window.App?.state && window.pages?.[window.App.state.page]?.module?.refresh?.();
}
if (failed > 0 && window.UI?.toast) {
window.UI.toast.warning(`${failed} Eintrag${failed > 1 ? 'e' : ''} noch nicht synchronisiert — kein Netz`);
}
return;
}
if (e.data?.type === 'QUEUE_COUNT') {
const badge = document.getElementById('offline-queue-badge');
if (badge) {
if (e.data.count > 0) {
badge.textContent = e.data.count;
badge.style.display = '';
} else {
badge.style.display = 'none';
}
}
return;
}
if (e.data?.type === 'CHECK_NEARBY_ALERTS') {
window.App?._checkNearbyAlerts?.();
}
});
}
</script>
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
<script src="/js/boot.js?v=1100"></script>
</body>

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '1099'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '1100'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator)
window.APP_VERSION = APP_VERSION;

View file

@ -0,0 +1,13 @@
/* Theme-Setup und theme-color für Status-Leiste.
MUSS synchron im <head> VOR den CSS-Links laufen, sonst FOUC. */
(function() {
var t = localStorage.getItem('by_theme');
var isDark = t === 'dark' || (t !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
var isAndroid = /android/i.test(navigator.userAgent);
if (t === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
if (t === 'light') document.documentElement.setAttribute('data-theme', 'light');
// Android: immer dunkel (Amber-Streifen nicht transparent möglich)
// iOS: black-translucent übernimmt das
var m = document.getElementById('meta-theme-color');
if (m) m.setAttribute('content', (isDark || isAndroid) ? '#0f1623' : '#C4843A');
})();

127
backend/static/js/boot.js Normal file
View file

@ -0,0 +1,127 @@
/* ============================================================
BAN YARO Boot-Phase
Offline-Banner + Service Worker Registration + Update-Flow
Extrahiert aus index.html für CSP-Härtung (kein unsafe-inline)
============================================================ */
// ----------------------------------------------------------
// Offline-Banner
// ----------------------------------------------------------
(function() {
function _updateBanner() {
var banner = document.getElementById('offline-banner');
if (!banner) return;
banner.style.display = navigator.onLine ? 'none' : 'flex';
}
window.addEventListener('offline', function() {
_updateBanner();
// Einmaliger Hinweis pro Session: App im Vordergrund lassen
if (!sessionStorage.getItem('by_offline_hint_shown')) {
sessionStorage.setItem('by_offline_hint_shown', '1');
setTimeout(function() {
window.UI?.toast?.info(
'App im Vordergrund lassen — so bleiben Offline-Funktionen wie GPS und Datenspeicherung aktiv.',
8000
);
}, 800);
}
// Queue-Count abfragen
if (navigator.serviceWorker) {
navigator.serviceWorker.ready.then(function(reg) {
if (reg.active) reg.active.postMessage({ type: 'QUEUE_COUNT' });
});
}
});
window.addEventListener('online', function() {
_updateBanner();
var badge = document.getElementById('offline-queue-badge');
if (badge) badge.style.display = 'none';
// Queue abarbeiten
if (navigator.serviceWorker) {
navigator.serviceWorker.ready.then(function(reg) {
if (reg.active) reg.active.postMessage({ type: 'PROCESS_QUEUE' });
});
}
});
_updateBanner();
})();
// ----------------------------------------------------------
// Service Worker Registration + Update-Flow
// ----------------------------------------------------------
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' })
.then(function(reg) {
function _watchSW(sw) {
if (!sw) return;
sw.addEventListener('statechange', function() {
if (sw.state === 'activated') {
if (sessionStorage.getItem('by_skip_sw_reload')) return;
window.location.replace('/?_t=' + Date.now());
}
});
}
reg.addEventListener('updatefound', function() { _watchSW(reg.installing); });
if (reg.installing) _watchSW(reg.installing);
reg.update();
})
.catch(function(err) { console.warn('SW Registration failed:', err); });
});
// App aus dem Hintergrund: erneut prüfen
document.addEventListener('visibilitychange', function() {
if (document.visibilityState === 'visible') {
navigator.serviceWorker.getRegistration().then(function(reg) { if (reg) reg.update(); });
}
});
// Backup: controllerchange falls updatefound nicht feuert
// NICHT registrieren wenn diese Seite selbst durch SW-Reload entstand
if (!window._BY_SW_RELOAD) {
navigator.serviceWorker.addEventListener('controllerchange', function() {
if (sessionStorage.getItem('by_skip_sw_reload')) {
sessionStorage.removeItem('by_skip_sw_reload');
return;
}
window.location.replace('/?_t=' + Date.now());
});
}
navigator.serviceWorker.addEventListener('message', function(e) {
if (e.data && e.data.type === 'QUEUE_PROCESSED') {
var synced = e.data.synced, failed = e.data.failed, total = e.data.total;
if (total === 0) return;
if (synced > 0 && window.UI && window.UI.toast) {
window.UI.toast.success(
synced === 1
? '1 offline gespeicherter Eintrag synchronisiert'
: synced + ' offline gespeicherte Einträge synchronisiert'
);
if (window.App && window.App.state && window.pages) {
var p = window.pages[window.App.state.page];
if (p && p.module && p.module.refresh) p.module.refresh();
}
}
if (failed > 0 && window.UI && window.UI.toast) {
window.UI.toast.warning(failed + ' Eintrag' + (failed > 1 ? 'e' : '') + ' noch nicht synchronisiert — kein Netz');
}
return;
}
if (e.data && e.data.type === 'QUEUE_COUNT') {
var badge = document.getElementById('offline-queue-badge');
if (badge) {
if (e.data.count > 0) {
badge.textContent = e.data.count;
badge.style.display = '';
} else {
badge.style.display = 'none';
}
}
return;
}
if (e.data && e.data.type === 'CHECK_NEARBY_ALERTS') {
if (window.App && window.App._checkNearbyAlerts) window.App._checkNearbyAlerts();
}
});
}

View file

@ -0,0 +1,99 @@
/* ============================================================
BAN YARO Landing Page Init
Dark-Mode-Check, Scroll-Animationen, Live-Stats, Stay-In-App
Extrahiert aus landing.html für CSP-Härtung
============================================================ */
// Dark Mode (CSS-Klasse)
(function() {
var mq = window.matchMedia('(prefers-color-scheme: dark)');
if (mq.matches) document.documentElement.classList.add('dark');
mq.addEventListener('change', function(e) {
document.documentElement.classList.toggle('dark', e.matches);
});
})();
document.addEventListener('DOMContentLoaded', function() {
// App-Links: kein Redirect-Loop (ersetzt onclick="sessionStorage.setItem(...)")
document.querySelectorAll('[data-stay-in-app]').forEach(function(el) {
el.addEventListener('click', function() {
sessionStorage.setItem('by_stay_in_app', '1');
});
});
// Hundebesitzer-Details-Toggle (ersetzt inline onclick)
document.querySelectorAll('[data-toggle-target]').forEach(function(el) {
el.addEventListener('click', function() {
var c = document.getElementById(el.dataset.toggleTarget);
if (!c) return;
c.classList.toggle('open');
var open = c.classList.contains('open');
var openTxt = el.dataset.toggleTextOpen || '▴ Weniger anzeigen';
var closeTxt = el.dataset.toggleTextClose || el.textContent;
if (!el.dataset.toggleTextClose) el.dataset.toggleTextClose = closeTxt;
el.textContent = open ? openTxt : el.dataset.toggleTextClose;
});
});
// Auch ältere App-Links erfassen (Fallback ohne data-stay-in-app)
document.querySelectorAll('a[href="/"], a[href^="/#"]').forEach(function(a) {
a.addEventListener('click', function() {
sessionStorage.setItem('by_stay_in_app', '1');
});
});
// Scroll-Animationen
var _observer = new IntersectionObserver(function(entries) {
entries.forEach(function(e) {
if (e.isIntersecting) {
e.target.classList.add('visible');
_observer.unobserve(e.target);
}
});
}, { threshold: 0.12 });
document.querySelectorAll('.outcome-card, .feature-card, .usp-item, .pricing-card').forEach(function(el) {
el.classList.add('fade-up');
_observer.observe(el);
});
document.querySelectorAll('.fade-up').forEach(function(el) {
_observer.observe(el);
});
// Live-Zahlen
var fmt = new Intl.NumberFormat('de-DE');
fetch('/api/stats/public')
.then(function(r) { return r.json(); })
.then(function(d) {
function set(id, val) {
var el = document.getElementById(id);
if (el) el.textContent = fmt.format(val);
}
set('big-users', d.users);
set('big-dogs', d.dogs);
set('big-km', d.km);
set('big-posts', d.forum_posts);
set('big-diary', d.diary_entries);
set('big-kotbeutel', d.kotbeutel);
var heroStats = document.getElementById('hero-stats');
if (!heroStats || !d.users) return;
var items = [
{ val: d.users, label: 'Hundemenschen' },
{ val: d.dogs, label: 'Hunde' },
{ val: d.km, label: 'km Gassi-Wege' },
{ val: d.diary_entries, label: 'Tagebuch-Einträge' },
{ val: d.kotbeutel, label: 'Mülleimer für Kotbeutel'},
];
items.sort(function(a, b) { return a.val - b.val; });
heroStats.innerHTML = items.map(function(item, i) {
return (i > 0 ? '<span class="sep">·</span>' : '') +
'<strong>' + fmt.format(item.val) + '</strong> ' + item.label;
}).join('');
heroStats.style.display = 'flex';
})
.catch(function() {});
});

View file

@ -4,13 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<script>
(function(){
var mq = window.matchMedia('(prefers-color-scheme: dark)');
if (mq.matches) document.documentElement.classList.add('dark');
mq.addEventListener('change', function(e){ document.documentElement.classList.toggle('dark', e.matches); });
})();
</script>
<script src="/js/landing-init.js?v=1100"></script>
<title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title>
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, ohne App Store.">
<meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz">
@ -731,7 +725,7 @@
<h1>Weil jeder Moment<br>mit ihm zählt.</h1>
<p>Ban Yaro begleitet euch durch jeden gemeinsamen Tag — Tagebuch, Training und Gesundheit für Hundebesitzer, Stammbaum und Wurfverwaltung für Züchter. Eine App. Mit ganzem Herzen.</p>
<div style="display:flex;gap:1rem;justify-content:center;flex-wrap:wrap;margin-top:2rem">
<a href="/" class="cta-btn" onclick="sessionStorage.setItem('by_stay_in_app','1')">Kostenlos starten</a>
<a href="/" class="cta-btn" data-stay-in-app>Kostenlos starten</a>
<a href="/zuechter" class="cta-btn" style="background:transparent;color:white;border:2px solid rgba(255,255,255,.6)">Ich bin Züchter</a>
</div>
<div class="header-badges" style="margin-top:1.5rem">
@ -849,7 +843,7 @@
</p>
<a href="/" class="cta-btn"
style="align-self:flex-start;background:white;color:#a86e2e;margin:0;box-shadow:0 4px 20px rgba(0,0,0,.2)"
onclick="sessionStorage.setItem('by_stay_in_app','1')">Kostenlos starten</a>
data-stay-in-app>Kostenlos starten</a>
</div>
</div>
@ -951,7 +945,9 @@
</div>
</div>
<span class="section-cta-link" onclick="var c=document.getElementById('hundebesitzer-details');c.classList.toggle('open');this.textContent=c.classList.contains('open')?'▴ Weniger anzeigen':'+ Alle Features für Hundebesitzer ansehen ▾'">+ Alle Features für Hundebesitzer ansehen ▾</span>
<span class="section-cta-link" data-toggle-target="hundebesitzer-details"
data-toggle-text-open="▴ Weniger anzeigen"
style="cursor:pointer">+ Alle Features für Hundebesitzer ansehen ▾</span>
<div id="hundebesitzer-details" class="collapsible-content">
<div class="feature-group">
@ -1402,7 +1398,7 @@
<li>Persönlichkeitstest, Adoption, Ausgaben</li>
</ul>
<a href="/" class="section-cta-btn" style="display:block;text-align:center;margin-top:1.5rem"
onclick="sessionStorage.setItem('by_stay_in_app','1')">Kostenlos starten</a>
data-stay-in-app>Kostenlos starten</a>
</div>
<!-- Pro -->
@ -1419,7 +1415,7 @@
<li>Alles aus Kostenlos inklusive</li>
</ul>
<a href="/" class="section-cta-btn" style="display:block;text-align:center;margin-top:1.5rem"
onclick="sessionStorage.setItem('by_stay_in_app','1')">Pro starten</a>
data-stay-in-app>Pro starten</a>
</div>
<!-- Züchter -->
@ -1606,70 +1602,6 @@
</div>
</footer>
<script>
// App-Links: kein Redirect-Loop
document.querySelectorAll('a[href="/"], a[href^="/#"]').forEach(function(a) {
a.addEventListener('click', function() {
sessionStorage.setItem('by_stay_in_app', '1');
});
});
// Scroll-Animationen
var _observer = new IntersectionObserver(function(entries) {
entries.forEach(function(e) {
if (e.isIntersecting) {
e.target.classList.add('visible');
_observer.unobserve(e.target);
}
});
}, { threshold: 0.12 });
document.querySelectorAll('.outcome-card, .feature-card, .usp-item, .pricing-card').forEach(function(el) {
el.classList.add('fade-up');
_observer.observe(el);
});
document.querySelectorAll('.fade-up').forEach(function(el) {
_observer.observe(el);
});
// Live-Zahlen von /api/stats/public
var fmt = new Intl.NumberFormat('de-DE');
fetch('/api/stats/public')
.then(function(r) { return r.json(); })
.then(function(d) {
function set(id, val) {
var el = document.getElementById(id);
if (el) el.textContent = fmt.format(val);
}
// Stats-Band (weiter unten)
set('big-users', d.users);
set('big-dogs', d.dogs);
set('big-km', d.km);
set('big-posts', d.forum_posts);
set('big-diary', d.diary_entries);
set('big-kotbeutel', d.kotbeutel);
// Hero-Streifen: aufsteigend nach Wert sortiert, dynamisch aufgebaut
var heroStats = document.getElementById('hero-stats');
if (!heroStats || !d.users) return;
var items = [
{ val: d.users, label: 'Hundemenschen' },
{ val: d.dogs, label: 'Hunde' },
{ val: d.km, label: 'km Gassi-Wege' },
{ val: d.diary_entries, label: 'Tagebuch-Einträge' },
{ val: d.kotbeutel, label: 'Mülleimer für Kotbeutel'},
];
items.sort(function(a, b) { return a.val - b.val; });
heroStats.innerHTML = items.map(function(item, i) {
return (i > 0 ? '<span class="sep">·</span>' : '') +
'<strong>' + fmt.format(item.val) + '</strong> ' + item.label;
}).join('');
heroStats.style.display = 'flex';
})
.catch(function() {});
</script>
</body>
</html>

View file

@ -4,7 +4,7 @@
============================================================ */
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab
const VER = '1099';
const VER = '1100';
const CACHE_VERSION = `by-v${VER}`;
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
@ -36,6 +36,8 @@ const STATIC_ASSETS = [
`/js/app.js?v=${VER}`,
`/js/worlds.js?v=${VER}`,
`/js/offline-indicator.js?v=${VER}`,
`/js/boot-early.js?v=${VER}`,
`/js/boot.js?v=${VER}`,
'/js/leaflet.markercluster.js',
'/css/MarkerCluster.css',
'/css/MarkerCluster.Default.css',