Feature: Welten-Onboarding, Wetter-Motivation, UX-Fixes (SW by-v715)

Welten (worlds.js):
- Swipe-Hints beim ersten Öffnen (JETZT ← → WELT animiert, einmalig)
- Kein-Hund-Onboarding: Feature-Preview-Grid statt leerer Karte
- Hintergrund-Foto-Hint: Kamera-Karte wenn noch kein Tagebuchfoto
- worlds-back: navigiert zu Welcome wenn kein User eingeloggt
- Nach Logout: worlds-back Button sofort ausgeblendet

Wetter (wetter.js):
- Standort-Fehlerseite zu Motivations-Seite umgebaut
- Feature-Preview: Gassi-Score, 7-Tage, Regenradar, Rekorde
- CTA: Standort freigeben + Registrieren (nur für Gäste)

Settings (settings.js):
- Logo in Auth-Form: display:block + margin:0 auto zentriert
- Header bleibt sichtbar (FAB/Zurück-Navigation funktioniert)

Jobs (jobs.js):
- 2-Spalten-Grid auf Mobile: auto-fit statt festes 1fr 1fr
- Kein doppeltes Padding im Wrapper

Backend:
- weather.py, achievements.py: diary JOIN fix (d.user_id → dogs JOIN)
- Neue Wetter-Badges: wetter_tapfer, jahreszeiten, schnee
- Ernährungs-, Reise-, Ausgaben-Seite: diverse UX-Verbesserungen
- Presse-Seite erweitert
- Ban Yaro Foto-Assets (WebP + HIRES JPG)
This commit is contained in:
rene 2026-05-05 17:32:03 +02:00
parent aa4849d947
commit 55069d246b
28 changed files with 719 additions and 198 deletions

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '700'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '715'; // ← 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';
@ -484,6 +484,9 @@ const App = (() => {
navigate('onboarding');
}
// Drei Welten nach Login starten (falls noch nicht initialisiert)
if (window.Worlds) window.Worlds.init(state);
_showVerifyBanner();
_updateNotifBadge();
_updateChatBadge();
@ -559,7 +562,8 @@ const App = (() => {
_updateHeaderUserBtn(false);
// Nicht eingeloggte User immer zur Welcome-Seite
window.Worlds?.hide();
document.getElementById('worlds-back')?.classList.remove('worlds-back-visible');
navigate('welcome', false);
}
@ -855,11 +859,8 @@ const App = (() => {
}
const startPage = (hashPage && pages[hashPage]) ? hashPage : 'welcome';
// Nicht eingeloggte User immer zur Welcome-Seite — auch bei direktem Link auf Forum, Map etc.
navigate(state.user ? startPage : 'welcome', false, hashParams);
// Drei Welten nach initialer Navigation starten (damit hide() in navigate() sie nicht gleich killt)
if (window.Worlds) window.Worlds.init(state);
if (window.Worlds && state.user) window.Worlds.init(state);
}
async function _handleInvite(token) {

View file

@ -125,47 +125,64 @@ window.Page_ernaehrung = (() => {
el.innerHTML = `
<div style="padding:var(--space-4) 0">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-4)">
Berechne den täglichen Kalorienbedarf deines Hundes.
</p>
<style>
.ern-pill-group { display:flex; gap:8px; flex-wrap:wrap; }
.ern-pill {
flex:1; min-width:0; padding:10px 8px; border-radius:12px;
border:1.5px solid var(--c-border); background:var(--c-bg-card);
color:var(--c-text-secondary); font-size:var(--text-xs); font-weight:600;
cursor:pointer; text-align:center; transition:all .15s; line-height:1.3;
}
.ern-pill.active {
background:var(--c-primary); color:#fff; border-color:var(--c-primary);
}
.ern-input-row {
display:grid; grid-template-columns:1fr 1fr; gap:var(--space-3);
margin-bottom:var(--space-4);
}
.ern-field { display:flex; flex-direction:column; gap:6px; }
.ern-field label { font-size:var(--text-xs); color:var(--c-text-secondary); font-weight:600; text-transform:uppercase; letter-spacing:.04em; }
.ern-field input { padding:12px 14px; border-radius:12px; border:1.5px solid var(--c-border); background:var(--c-bg-card); color:var(--c-text); font-size:var(--text-base); font-weight:700; width:100%; box-sizing:border-box; }
.ern-section-label { font-size:var(--text-xs); color:var(--c-text-secondary); font-weight:600; text-transform:uppercase; letter-spacing:.04em; margin-bottom:8px; }
</style>
<div class="by-form-group">
<label class="by-label">Gewicht (kg)</label>
<input id="ern-gewicht" type="number" step="0.1" min="0.5" max="100"
class="by-input" value="${_esc(gewichtDefault)}" placeholder="z. B. 15">
</div>
<div class="by-form-group">
<label class="by-label">Alter (Jahre)</label>
<input id="ern-alter" type="number" step="0.5" min="0" max="25"
class="by-input" value="${_esc(alterDefault)}" placeholder="z. B. 3">
</div>
<div class="by-form-group">
<label class="by-label">Aktivität</label>
<select id="ern-aktivitaet" class="by-select">
<option value="gering">Gering (Couch-Hund)</option>
<option value="normal" selected>Normal</option>
<option value="aktiv">Aktiv</option>
<option value="sport">Sehr aktiv (Sport)</option>
</select>
</div>
<div class="by-form-group">
<label class="by-label">Kastriert</label>
<div style="display:flex;gap:var(--space-3)">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
<input type="radio" name="ern-kastriert" value="ja"> Ja
</label>
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
<input type="radio" name="ern-kastriert" value="nein" checked> Nein
</label>
<!-- Gewicht + Alter nebeneinander -->
<div class="ern-input-row">
<div class="ern-field">
<label> Gewicht (kg)</label>
<input id="ern-gewicht" type="number" step="0.1" min="0.5" max="100"
value="${_esc(gewichtDefault)}" placeholder="15">
</div>
<div class="ern-field">
<label>🎂 Alter (Jahre)</label>
<input id="ern-alter" type="number" step="0.5" min="0" max="25"
value="${_esc(alterDefault)}" placeholder="3">
</div>
</div>
<button class="btn btn-primary" id="ern-rechner-btn" style="width:100%">
<!-- Aktivität als Pill-Buttons -->
<div style="margin-bottom:var(--space-4)">
<div class="ern-section-label">🏃 Aktivität</div>
<div class="ern-pill-group">
<button class="ern-pill" data-akt="gering">🛋 Gemütlich</button>
<button class="ern-pill active" data-akt="normal">🚶 Normal</button>
<button class="ern-pill" data-akt="aktiv">🏃 Aktiv</button>
<button class="ern-pill" data-akt="sport">🏅 Sportlich</button>
</div>
</div>
<!-- Kastriert als Pill-Buttons -->
<div style="margin-bottom:var(--space-5)">
<div class="ern-section-label"> Kastriert / Sterilisiert</div>
<div class="ern-pill-group">
<button class="ern-pill active" data-kas="nein" style="flex:none;width:calc(50% - 4px)">Nein</button>
<button class="ern-pill" data-kas="ja" style="flex:none;width:calc(50% - 4px)">Ja</button>
</div>
</div>
<button class="btn btn-primary" id="ern-rechner-btn" style="width:100%;padding:14px;font-size:var(--text-base)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#calculator"></use></svg>
Berechnen
Kalorienbedarf berechnen
</button>
<div id="ern-rechner-result" style="display:none;margin-top:var(--space-5)"></div>
@ -208,13 +225,28 @@ window.Page_ernaehrung = (() => {
</div>
`;
// Aktivität Pills
el.querySelectorAll('[data-akt]').forEach(btn => {
btn.addEventListener('click', () => {
el.querySelectorAll('[data-akt]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// Kastriert Pills
el.querySelectorAll('[data-kas]').forEach(btn => {
btn.addEventListener('click', () => {
el.querySelectorAll('[data-kas]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
el.querySelector('#ern-rechner-btn').addEventListener('click', () => _berechne(el));
}
function _berechne(el) {
const gewicht = parseFloat(el.querySelector('#ern-gewicht').value);
const aktivitaet = el.querySelector('#ern-aktivitaet').value;
const kastriert = el.querySelector('input[name="ern-kastriert"]:checked')?.value === 'ja';
const gewicht = parseFloat(el.querySelector('#ern-gewicht').value);
const aktivitaet = el.querySelector('[data-akt].active')?.dataset.akt || 'normal';
const kastriert = el.querySelector('[data-kas].active')?.dataset.kas === 'ja';
if (!gewicht || gewicht < 0.5) {
UI.toast.warning('Bitte ein gültiges Gewicht eingeben.');

View file

@ -5,15 +5,26 @@
window.Page_expenses = (() => {
let _container = null;
let _appState = null;
let _tab = 'uebersicht';
let _container = null;
let _appState = null;
let _tab = 'uebersicht';
let _selectedDogId = null;
// Cache
let _summary = null;
let _entries = [];
let _statsData = null;
function _dogParam() {
return _selectedDogId ? `?dog_id=${_selectedDogId}` : '';
}
function _dogParamAnd() {
return _selectedDogId ? `&dog_id=${_selectedDogId}` : '';
}
function _clearCache() {
_summary = null; _entries = []; _statsData = null;
}
const TABS = [
{ id: 'uebersicht', label: 'Übersicht', icon: 'house-line' },
{ id: 'eintraege', label: 'Ausgaben', icon: 'currency-eur' },
@ -38,11 +49,10 @@ window.Page_expenses = (() => {
// LIFECYCLE
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_summary = null;
_entries = [];
_statsData = null;
_container = container;
_appState = appState;
_selectedDogId = null;
_clearCache();
_render();
}
@ -56,6 +66,16 @@ window.Page_expenses = (() => {
// ----------------------------------------------------------
// SHELL
// ----------------------------------------------------------
function _dogSelectorHtml() {
const dogs = _appState?.dogs || [];
if (dogs.length < 2) return '';
const pills = [{ id: null, name: 'Alle' }, ...dogs].map(d => `
<button class="exp-dog-pill${_selectedDogId === d.id ? ' active' : ''}" data-dog="${d.id ?? ''}">
${d.id ? UI.icon('paw-print') : ''} ${_esc(d.name)}
</button>`).join('');
return `<div class="exp-dog-selector" id="exp-dog-selector">${pills}</div>`;
}
function _render() {
_container.innerHTML = `
<div class="by-tabs exp-tabs" id="exp-tabs">
@ -65,6 +85,7 @@ window.Page_expenses = (() => {
</button>
`).join('')}
</div>
${_dogSelectorHtml()}
<div id="exp-content"></div>
<button class="exp-fab" id="exp-fab" title="Neue Ausgabe">
${UI.icon('plus')}
@ -81,6 +102,17 @@ window.Page_expenses = (() => {
});
});
_container.querySelector('#exp-dog-selector')?.addEventListener('click', e => {
const pill = e.target.closest('.exp-dog-pill');
if (!pill) return;
_selectedDogId = pill.dataset.dog ? parseInt(pill.dataset.dog) : null;
_clearCache();
_container.querySelectorAll('.exp-dog-pill').forEach(p =>
p.classList.toggle('active', p.dataset.dog === (pill.dataset.dog))
);
_renderTab();
});
_container.querySelector('#exp-fab')
?.addEventListener('click', () => _showForm(null));
@ -111,7 +143,7 @@ window.Page_expenses = (() => {
// ----------------------------------------------------------
async function _renderUebersicht(el) {
if (!_summary) {
_summary = await API.get('/expenses/summary');
_summary = await API.get('/expenses/summary' + _dogParam());
}
const s = _summary;
@ -120,14 +152,20 @@ window.Page_expenses = (() => {
const trendHtml = _trendHtml(letzteMonat);
const kacheln = KATEGORIEN.map(k => {
const betrag = s.monat[k.id] || 0;
const monat = s.monat[k.id] || 0;
const jahr = s.jahr[k.id] || 0;
const monatLine = monat > 0
? `<div class="exp-kachel-jahr">${_fmt(monat)} diesen Monat</div>`
: '';
return `
<div class="exp-kachel">
<div class="exp-kachel" data-kat="${k.id}" style="cursor:pointer">
<div class="exp-kachel-icon" style="background:${k.color}20;color:${k.color}">
${UI.icon(k.icon)}
</div>
<div class="exp-kachel-betrag" style="color:var(--c-primary)">${_fmt(betrag)}</div>
<div class="exp-kachel-betrag" style="color:var(--c-primary)">${_fmt(jahr)}</div>
<div class="exp-kachel-label">${k.label}</div>
${monatLine}
<div class="exp-kachel-add">${UI.icon('plus')} eintragen</div>
</div>`;
}).join('');
@ -146,11 +184,15 @@ window.Page_expenses = (() => {
${verlauf}
<div style="height:80px"></div>
`;
el.querySelectorAll('.exp-kachel[data-kat]').forEach(k => {
k.addEventListener('click', () => _showForm(null, k.dataset.kat));
});
}
async function _getLetzteMonateData() {
if (!_entries.length) {
_entries = await API.get('/expenses?limit=500');
_entries = await API.get('/expenses?limit=500' + _dogParamAnd());
}
const monatMap = {};
_entries.forEach(e => {
@ -208,7 +250,7 @@ window.Page_expenses = (() => {
// ----------------------------------------------------------
async function _renderEintraege(el) {
if (!_entries.length) {
_entries = await API.get('/expenses?limit=500');
_entries = await API.get('/expenses?limit=500' + _dogParamAnd());
}
if (!_entries.length) {
@ -321,6 +363,7 @@ window.Page_expenses = (() => {
async function _renderDauerauftraege(el) {
let recurring = [];
try { recurring = await API.get('/expenses/recurring'); } catch { /* leer */ }
if (_selectedDogId) recurring = recurring.filter(r => r.dog_id === _selectedDogId);
const cards = recurring.map(r => {
const k = _kat(r.kategorie);
@ -481,10 +524,10 @@ window.Page_expenses = (() => {
// ----------------------------------------------------------
async function _renderStatistik(el) {
if (!_summary) {
_summary = await API.get('/expenses/summary');
_summary = await API.get('/expenses/summary' + _dogParam());
}
if (!_entries.length) {
_entries = await API.get('/expenses?limit=500');
_entries = await API.get('/expenses?limit=500' + _dogParamAnd());
}
const s = _summary;
@ -637,14 +680,15 @@ window.Page_expenses = (() => {
// ----------------------------------------------------------
// FORMULAR — Neu / Bearbeiten
// ----------------------------------------------------------
function _showForm(entry) {
function _showForm(entry, preKat) {
const isEdit = !!entry;
const today = new Date().toISOString().split('T')[0];
const formId = 'exp-form';
const selKat = entry?.kategorie || 'sonstiges';
const selKat = entry?.kategorie || preKat || 'sonstiges';
const defaultDogId = entry?.dog_id ?? _selectedDogId;
const dogOptions = (_appState.dogs || []).map(d =>
`<option value="${d.id}"${entry?.dog_id === d.id ? ' selected' : ''}>${_esc(d.name)}</option>`
`<option value="${d.id}"${defaultDogId === d.id ? ' selected' : ''}>${_esc(d.name)}</option>`
).join('');
// Kategorie-Kacheln statt Dropdown
@ -730,6 +774,9 @@ window.Page_expenses = (() => {
const modal = UI.modal.open({ title: isEdit ? 'Ausgabe bearbeiten' : 'Neue Ausgabe', body, footer });
// Betrag-Feld fokussieren (besonders beim Schnelleintrag per Kachel)
setTimeout(() => modal.querySelector('input[name="betrag"]')?.focus(), 200);
// Kategorie-Kacheln interaktiv
modal.querySelectorAll('.exp-kat-tile').forEach(tile => {
tile.addEventListener('click', () => {

View file

@ -30,7 +30,7 @@ window.Page_jobs = (() => {
}
_container.innerHTML = `
<div style="max-width:640px;margin:0 auto;padding:var(--space-4)">
<div style="max-width:640px;margin:0 auto;padding:0;box-sizing:border-box">
<!-- Header -->
<div style="text-align:center;margin-bottom:var(--space-6)">
@ -156,7 +156,7 @@ window.Page_jobs = (() => {
value="${u ? _esc(u.email || '') : ''}" placeholder="deine@email.de" required>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:var(--space-3)">
<div class="form-group" style="margin:0">
<label class="form-label">Hunde-Name</label>
<input class="form-control" type="text" name="dog_name" placeholder="z. B. Bella">

View file

@ -840,15 +840,21 @@ window.Page_map = (() => {
// Einzelne Basis-Typen — Mehrfachauswahl möglich (außer giftkoeder = exklusiv)
const PIN_TYPES = [
{ type: 'giftkoeder', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#skull"></use></svg>', label: 'Giftköder', color: '#DC2626', exclusive: true },
{ type: 'waste_basket', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>', label: 'Mülleimer', color: '#6B7280' },
{ type: 'kotbeutel', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bag"></use></svg>', label: 'Kotbeutel', color: '#84A98C' },
{ type: 'bank', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#park"></use></svg>', label: 'Sitzbank', color: '#92400E' },
{ type: 'drinking_water',icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#drop"></use></svg>', label: 'Wasserstelle',color: '#0EA5E9' },
{ type: 'dog_park', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>', label: 'Hundewiese', color: '#15803D' },
{ type: 'parkplatz', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>', label: 'Parkplatz', color: '#2563EB' },
{ type: 'treffpunkt', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg>', label: 'Treffpunkt', color: '#7C3AED' },
{ type: 'sonstiges', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>', label: 'Sonstiges', color: '#F59E0B' },
{ type: 'giftkoeder', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#skull"></use></svg>', label: 'Giftköder', color: '#DC2626', exclusive: true },
{ type: 'gefahr', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>', label: 'Gefahr', color: '#F59E0B' },
{ type: 'freilauf', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>', label: 'Freilauf', color: '#22C55E' },
{ type: 'dog_park', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>', label: 'Hundewiese', color: '#15803D' },
{ type: 'restaurant', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#fork-knife"></use></svg>', label: 'Restaurant', color: '#F97316' },
{ type: 'shop', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shopping-cart"></use></svg>', label: 'Shop', color: '#3B82F6' },
{ type: 'tierarzt', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>', label: 'Tierarzt', color: '#EF4444' },
{ type: 'hundeschule', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#graduation-cap"></use></svg>', label: 'Hundeschule', color: '#8B5CF6' },
{ type: 'waste_basket', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>', label: 'Mülleimer', color: '#6B7280' },
{ type: 'kotbeutel', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bag"></use></svg>', label: 'Kotbeutel', color: '#84A98C' },
{ type: 'bank', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#park"></use></svg>', label: 'Sitzbank', color: '#92400E' },
{ type: 'drinking_water',icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#drop"></use></svg>', label: 'Wasserstelle',color: '#0EA5E9' },
{ type: 'parkplatz', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>', label: 'Parkplatz', color: '#2563EB' },
{ type: 'treffpunkt', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg>', label: 'Treffpunkt', color: '#7C3AED' },
{ type: 'sonstiges', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>', label: 'Sonstiges', color: '#F59E0B' },
];
function _confirmPlacement(latlng) {

View file

@ -12,7 +12,6 @@ window.Page_reise = (() => {
const TABS = [
{ key: 'checkliste', label: 'Checkliste', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#check-square"></use></svg>' },
{ key: 'laender', label: 'EU-Länder', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#globe"></use></svg>' },
{ key: 'notfall', label: 'Notfälle', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' },
];
const CHECKLIST = [
@ -94,7 +93,15 @@ window.Page_reise = (() => {
{ icon: 'heartbeat', text: 'Bewusstlosigkeit: Atemwege freihalten, stabile Seitenlage, 112 rufen' },
];
const LS_KEY = 'banyaro_reise_checkliste';
const LS_KEY = 'banyaro_reise_checkliste';
const LS_CUSTOM_KEY = 'banyaro_reise_custom'; // {catKey: ["custom item",...]}
const LS_HIDDEN_KEY = 'banyaro_reise_hidden'; // {itemKey: true} — gelöschte Standard-Items
let _editMode = false;
function _loadCustom() { try { return JSON.parse(localStorage.getItem(LS_CUSTOM_KEY) || '{}'); } catch { return {}; } }
function _saveCustom(d) { try { localStorage.setItem(LS_CUSTOM_KEY, JSON.stringify(d)); } catch {} }
function _loadHidden() { try { return JSON.parse(localStorage.getItem(LS_HIDDEN_KEY) || '{}'); } catch { return {}; } }
function _saveHidden(d) { try { localStorage.setItem(LS_HIDDEN_KEY, JSON.stringify(d)); } catch {} }
// ------------------------------------------------------------------
// Helpers
@ -177,37 +184,79 @@ window.Page_reise = (() => {
// ------------------------------------------------------------------
function _renderCheckliste(el) {
const checked = _loadChecked();
const custom = _loadCustom();
const hidden = _loadHidden();
const totalItems = CHECKLIST.reduce((s, c) => s + c.items.length, 0);
const doneItems = Object.values(checked).filter(Boolean).length;
const pct = totalItems > 0 ? Math.round((doneItems / totalItems) * 100) : 0;
const progressBar = `
<div style="margin-bottom:var(--space-5)">
<div style="display:flex;justify-content:space-between;align-items:center;
margin-bottom:var(--space-2)">
<span style="font-size:var(--text-sm);color:var(--c-text-secondary)">
${doneItems} von ${totalItems} erledigt
</span>
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-primary)">${pct}%</span>
</div>
<div style="height:8px;background:var(--c-surface-2);border-radius:var(--radius-full);overflow:hidden">
<div style="height:100%;width:${pct}%;background:var(--c-primary);
border-radius:var(--radius-full);transition:width .3s ease"></div>
</div>
</div>`;
// Alle sichtbaren Items zählen
let totalItems = 0, doneItems = 0;
CHECKLIST.forEach(cat => {
cat.items.forEach((_, idx) => {
if (!hidden[_itemKey(cat.key, idx)]) {
totalItems++;
if (checked[_itemKey(cat.key, idx)]) doneItems++;
}
});
(custom[cat.key] || []).forEach((_, i) => {
totalItems++;
if (checked[`${cat.key}__custom__${i}`]) doneItems++;
});
});
const pct = totalItems > 0 ? Math.round((doneItems / totalItems) * 100) : 0;
const cats = CHECKLIST.map(cat => {
const rows = cat.items.map((item, idx) => {
const customItems = custom[cat.key] || [];
const stdRows = cat.items.map((item, idx) => {
if (hidden[_itemKey(cat.key, idx)]) return '';
const key = _itemKey(cat.key, idx);
const done = !!checked[key];
return `<label class="reise-check-row${done ? ' done' : ''}" data-key="${_esc(key)}">
if (_editMode) {
return `<div class="reise-check-row" style="justify-content:space-between">
<span style="flex:1;color:var(--c-text)">${_esc(item)}</span>
<button class="reise-del-btn" data-hide="${_esc(key)}"
style="background:none;border:none;color:#EF4444;cursor:pointer;padding:4px;flex-shrink:0">
<svg class="ph-icon" style="width:1rem;height:1rem"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
</div>`;
}
return `<label class="reise-check-row${done ? ' done' : ''}">
<input type="checkbox" class="reise-cb" data-key="${_esc(key)}" ${done ? 'checked' : ''}>
<span>${_esc(item)}</span>
</label>`;
}).join('');
const customRows = customItems.map((item, i) => {
const key = `${cat.key}__custom__${i}`;
const done = !!checked[key];
if (_editMode) {
return `<div class="reise-check-row" style="justify-content:space-between">
<span style="flex:1;color:var(--c-primary)">${_esc(item)}</span>
<button class="reise-del-custom-btn" data-cat="${_esc(cat.key)}" data-idx="${i}"
style="background:none;border:none;color:#EF4444;cursor:pointer;padding:4px;flex-shrink:0">
<svg class="ph-icon" style="width:1rem;height:1rem"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
</div>`;
}
return `<label class="reise-check-row${done ? ' done' : ''}">
<input type="checkbox" class="reise-cb" data-key="${_esc(key)}" ${done ? 'checked' : ''}>
<span style="color:var(--c-primary)">${_esc(item)}</span>
</label>`;
}).join('');
const addRow = _editMode ? `
<div style="padding:var(--space-2) 0;border-top:1px dashed var(--c-border);margin-top:4px">
<div style="display:flex;gap:8px;align-items:center">
<input class="reise-add-input" data-cat="${_esc(cat.key)}"
style="flex:1;padding:8px 10px;border-radius:8px;border:1px solid var(--c-border);
background:var(--c-bg-card);color:var(--c-text);font-size:var(--text-sm)"
placeholder="Eigenes Item hinzufügen…">
<button class="reise-add-btn btn btn-primary" data-cat="${_esc(cat.key)}"
style="padding:8px 12px;flex-shrink:0;font-size:var(--text-sm)">
<svg class="ph-icon" style="width:1rem;height:1rem"><use href="/icons/phosphor.svg#plus"></use></svg>
</button>
</div>
</div>` : '';
return `<div class="card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--c-border);
display:flex;align-items:center;gap:var(--space-2)">
@ -217,20 +266,12 @@ window.Page_reise = (() => {
<span style="font-weight:var(--weight-semibold)">${_esc(cat.label)}</span>
</div>
<div style="padding:var(--space-2) var(--space-4)">
${rows}
${stdRows}${customRows}${addRow}
</div>
</div>`;
}).join('');
el.innerHTML = `
${progressBar}
${cats}
<div style="text-align:center;margin-bottom:var(--space-6)">
<button class="btn btn-secondary" id="reise-reset-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#arrow-counter-clockwise"></use></svg>
Alle zurücksetzen
</button>
</div>
<style>
.reise-check-row {
display:flex;align-items:flex-start;gap:var(--space-3);
@ -245,6 +286,32 @@ window.Page_reise = (() => {
accent-color:var(--c-primary);cursor:pointer;
}
</style>
<!-- Fortschritt + Buttons -->
<div style="margin-bottom:var(--space-5)">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-2)">
<span style="font-size:var(--text-sm);color:var(--c-text-secondary)">${doneItems} von ${totalItems} erledigt</span>
<div style="display:flex;gap:8px;align-items:center">
<span style="font-size:var(--text-sm);font-weight:700;color:var(--c-primary)">${pct}%</span>
<button id="reise-edit-toggle" style="background:${_editMode ? 'var(--c-primary)' : 'var(--c-bg-card)'};
color:${_editMode ? '#fff' : 'var(--c-text-secondary)'};border:1.5px solid var(--c-border);
border-radius:8px;padding:5px 10px;cursor:pointer;font-size:var(--text-xs);font-weight:600;
display:flex;align-items:center;gap:4px">
<svg class="ph-icon" style="width:.9rem;height:.9rem"><use href="/icons/phosphor.svg#pencil-simple"></use></svg>
${_editMode ? 'Fertig' : 'Bearbeiten'}
</button>
</div>
</div>
<div style="height:8px;background:var(--c-surface-2);border-radius:var(--radius-full);overflow:hidden">
<div style="height:100%;width:${pct}%;background:var(--c-primary);border-radius:var(--radius-full);transition:width .3s"></div>
</div>
</div>
${cats}
${!_editMode ? `<div style="text-align:center;margin-bottom:var(--space-6)">
<button class="btn btn-secondary" id="reise-reset-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#arrow-counter-clockwise"></use></svg>
Abhaken zurücksetzen
</button>
</div>` : ''}
`;
// Checkbox events
@ -254,7 +321,55 @@ window.Page_reise = (() => {
const cur = _loadChecked();
cur[key] = cb.checked;
_saveChecked(cur);
_renderTabContent(); // re-render to update progress
_renderTabContent();
});
});
// Edit-Toggle
el.querySelector('#reise-edit-toggle')?.addEventListener('click', () => {
_editMode = !_editMode;
_renderTabContent();
});
// Standard-Item löschen (verstecken)
el.querySelectorAll('.reise-del-btn').forEach(btn => {
btn.addEventListener('click', () => {
const h = _loadHidden();
h[btn.dataset.hide] = true;
_saveHidden(h);
_renderTabContent();
});
});
// Custom-Item löschen
el.querySelectorAll('.reise-del-custom-btn').forEach(btn => {
btn.addEventListener('click', () => {
const c = _loadCustom();
c[btn.dataset.cat] = (c[btn.dataset.cat] || []).filter((_, i) => i !== parseInt(btn.dataset.idx));
_saveCustom(c);
_renderTabContent();
});
});
// Custom-Item hinzufügen
el.querySelectorAll('.reise-add-btn').forEach(btn => {
btn.addEventListener('click', () => {
const cat = btn.dataset.cat;
const input = el.querySelector(`.reise-add-input[data-cat="${cat}"]`);
const val = (input?.value || '').trim();
if (!val) return;
const c = _loadCustom();
if (!c[cat]) c[cat] = [];
c[cat].push(val);
_saveCustom(c);
_renderTabContent();
});
});
// Enter in Add-Input
el.querySelectorAll('.reise-add-input').forEach(input => {
input.addEventListener('keydown', e => {
if (e.key === 'Enter') el.querySelector(`.reise-add-btn[data-cat="${input.dataset.cat}"]`)?.click();
});
});

View file

@ -1424,15 +1424,15 @@ window.Page_settings = (() => {
_mode = mode;
_container.innerHTML = `
<div style="max-width:380px;margin:0 auto;padding:var(--space-6) 0">
<div style="max-width:380px;width:100%;margin:0 auto;padding:var(--space-6) 0;box-sizing:border-box">
<!-- Logo -->
<div style="text-align:center;margin-bottom:var(--space-6)">
<img src="/icons/icon-180.png" alt="Ban Yaro"
style="width:72px;height:72px;border-radius:var(--radius-lg);
margin-bottom:var(--space-3)">
<h1 style="font-size:var(--text-2xl);font-weight:700;margin:0">Ban Yaro</h1>
<p style="color:var(--c-text-secondary);margin:var(--space-1) 0 0">
display:block;margin:0 auto var(--space-3)">
<h1 style="font-size:var(--text-2xl);font-weight:700;margin:0;text-align:center">Ban Yaro</h1>
<p style="color:var(--c-text-secondary);margin:var(--space-1) 0 0;text-align:center">
Alles rund um deinen Hund
</p>
</div>

View file

@ -497,9 +497,16 @@ window.Page_welcome = (() => {
API.dogs.welcomeDashboard(dog.id).then(dash => {
_updateHeroFromDash(dash, dog);
_updateChipsFromDash(dash);
_tryRouteChip(dash); // nach Chips-Update: ggf. Gassirunden-Vorschlag einfügen
_tryRouteChip(dash);
}).catch(() => { /* Skeleton bleibt sichtbar */ });
// Hero-Foto stündlich auffrischen (Tageswechsel um Mitternacht sichtbar ohne Reload)
setInterval(() => {
API.dogs.welcomeDashboard(dog.id)
.then(dash => _updateHeroFromDash(dash, dog))
.catch(() => {});
}, 60 * 60 * 1000);
// Streak-Widget asynchron laden
_loadStreakWidget(dog.id);
}

View file

@ -112,22 +112,82 @@ window.Page_wetter = (() => {
function _showLocationError() {
const body = _container.querySelector('#wttr-body');
if (!body) return;
const isLoggedIn = !!_appState?.user;
body.innerHTML = `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="font-size:2.5rem;margin-bottom:var(--space-3)">📍</div>
<h3 style="margin-bottom:var(--space-2)">Standort nicht verfügbar</h3>
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-5);max-width:300px;margin-inline:auto">
Bitte erlaube den Zugriff auf deinen Standort, um die Wettervorhersage zu laden.
</p>
<button class="btn btn-primary" id="wttr-btn-retry">
${UI.icon('map-pin')} Nochmal versuchen
</button>
<div style="max-width:420px;margin:0 auto;padding:var(--space-6) var(--space-4)">
<!-- Hero -->
<div style="text-align:center;margin-bottom:var(--space-6)">
<div style="font-size:4rem;line-height:1;margin-bottom:var(--space-2)">🌤🐾</div>
<h2 style="font-size:var(--text-xl);font-weight:800;margin:0 0 var(--space-2)">
Das Gassi-Wetter wartet auf dich
</h2>
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);margin:0">
Erfahre sekundengenau, ob gerade der perfekte Moment für eine Runde ist
zugeschnitten auf dich und deinen Hund.
</p>
</div>
<!-- Feature-Liste -->
<div style="display:flex;flex-direction:column;gap:var(--space-3);margin-bottom:var(--space-6)">
${[
['sun', '#F59E0B', 'Gassi-Score 110', 'Wetter bewertet nach Temperatur, Regen und Wind'],
['thermometer', '#3B82F6', '7-Tage-Vorschau', 'Plane deine Runden für die ganze Woche voraus'],
['drop', '#06B6D4', 'Regenradar stündlich', '24h-Niederschlagstimeline auf einen Blick'],
['trophy', '#10B981', 'Wetter-Rekorde', 'Wärmster, nassester und stürmischster Gassi-Tag'],
].map(([icon, color, title, sub]) => `
<div style="display:flex;align-items:center;gap:var(--space-3);
background:var(--c-bg-card);border:1px solid var(--c-border);
border-radius:var(--radius-lg);padding:var(--space-3) var(--space-4)">
<div style="width:38px;height:38px;border-radius:var(--radius-md);
background:${color}18;display:flex;align-items:center;
justify-content:center;flex-shrink:0">
<svg class="ph-icon" style="width:1.1rem;height:1.1rem;color:${color}">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
</div>
<div>
<div style="font-weight:700;font-size:var(--text-sm)">${title}</div>
<div style="color:var(--c-text-secondary);font-size:var(--text-xs)">${sub}</div>
</div>
</div>
`).join('')}
</div>
<!-- CTAs -->
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
<button class="btn btn-primary" id="wttr-btn-retry"
style="display:flex;align-items:center;justify-content:center;gap:var(--space-2)">
<svg class="ph-icon" style="width:1rem;height:1rem">
<use href="/icons/phosphor.svg#map-pin"></use>
</svg>
Standort freigeben &amp; loslegen
</button>
${!isLoggedIn ? `
<button class="btn btn-secondary" id="wttr-btn-login"
style="display:flex;align-items:center;justify-content:center;gap:var(--space-2)">
<svg class="ph-icon" style="width:1rem;height:1rem">
<use href="/icons/phosphor.svg#user"></use>
</svg>
Kostenlos registrieren
</button>
<p style="text-align:center;font-size:var(--text-xs);color:var(--c-text-secondary);margin:0">
Mit Account werden Rekorde &amp; Gassi-Score für deinen Hund gespeichert.
</p>
` : ''}
</div>
</div>
`;
body.querySelector('#wttr-btn-retry')?.addEventListener('click', () => {
_renderShell();
_tryAutoLocate();
});
body.querySelector('#wttr-btn-login')?.addEventListener('click', () => {
if (window.App) App.navigate('settings');
});
}
// ----------------------------------------------------------
@ -1007,19 +1067,21 @@ window.Page_wetter = (() => {
function _recordCard(emoji, title, value, subtitle, color) {
return `
<div style="background:var(--c-bg-card);border:1px solid var(--c-border);
border-radius:var(--radius);padding:var(--space-3) var(--space-3);
display:flex;flex-direction:column;gap:2px">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
display:flex;align-items:center;gap:4px;font-weight:600">
<div style="background:${color}10;border:1px solid ${color}33;
border-radius:var(--radius);padding:var(--space-3);
display:flex;flex-direction:column;gap:3px">
<div style="font-size:10px;color:var(--c-text-secondary);
display:flex;align-items:center;gap:3px;font-weight:700;
text-transform:uppercase;letter-spacing:.04em">
<span>${emoji}</span>
<span>${_esc(title)}</span>
</div>
<div style="font-size:var(--text-xl);font-weight:800;color:${color};line-height:1.1">
<div style="font-size:var(--text-lg);font-weight:800;color:${color};line-height:1.1">
${_esc(value)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
<div style="font-size:10px;color:var(--c-text-secondary);
overflow:hidden;display:-webkit-box;
-webkit-line-clamp:2;-webkit-box-orient:vertical;line-height:1.3">
${_esc(subtitle)}
</div>
</div>

View file

@ -13,6 +13,7 @@ window.Worlds = (() => {
let _lastUserId = undefined;
let _dogs = []; // gecachte Hundesliste
let _dogIdx = 0; // aktuell angezeigter Hund
let _hasBgPhoto = false; // Hintergrund-Foto vorhanden?
// Touch-Tracking
const _t = { x:0, y:0, active:false, vert:null, moved:0 };
@ -49,6 +50,55 @@ window.Worlds = (() => {
_setupButtons();
_goTo(_cur, false);
show();
_showSwipeHints();
}
function _showSwipeHints() {
if (localStorage.getItem('worlds_swipe_seen')) return;
localStorage.setItem('worlds_swipe_seen', '1');
const ov = document.getElementById('worlds-overlay');
if (!ov) return;
const hint = document.createElement('div');
hint.style.cssText = [
'position:absolute;inset:0;pointer-events:none;z-index:55',
'display:flex;align-items:center;justify-content:space-between',
'padding:0 8px;transition:opacity 1s ease',
].join(';');
const arrowStyle = `
display:flex;flex-direction:column;align-items:center;gap:4px;
background:rgba(0,0,0,0.42);backdrop-filter:blur(10px);
-webkit-backdrop-filter:blur(10px);
border:1px solid rgba(255,255,255,0.18);border-radius:14px;
padding:10px 10px;animation:worlds-pulse 1.2s ease infinite alternate;
`;
hint.innerHTML = `
<style>
@keyframes worlds-pulse {
from { opacity:0.75; transform:translateX(0); }
to { opacity:1; transform:translateX(-3px); }
}
.wsh-right { animation-name:worlds-pulse-r !important; }
@keyframes worlds-pulse-r {
from { opacity:0.75; transform:translateX(0); }
to { opacity:1; transform:translateX(3px); }
}
</style>
<div style="${arrowStyle}">
<svg style="width:20px;height:20px;color:white" viewBox="0 0 256 256">
<path fill="currentColor" d="M165.66 202.34a8 8 0 0 1-11.32 11.32l-80-80a8 8 0 0 1 0-11.32l80-80a8 8 0 0 1 11.32 11.32L91.31 128Z"/>
</svg>
<span style="font-size:8px;font-weight:800;letter-spacing:.12em;color:rgba(255,255,255,0.7);text-transform:uppercase">JETZT</span>
</div>
<div class="wsh-right" style="${arrowStyle}">
<svg style="width:20px;height:20px;color:white" viewBox="0 0 256 256">
<path fill="currentColor" d="M90.34 53.66a8 8 0 0 1 11.32-11.32l80 80a8 8 0 0 1 0 11.32l-80 80a8 8 0 0 1-11.32-11.32L164.69 128Z"/>
</svg>
<span style="font-size:8px;font-weight:800;letter-spacing:.12em;color:rgba(255,255,255,0.7);text-transform:uppercase">WELT</span>
</div>
`;
ov.appendChild(hint);
setTimeout(() => { hint.style.opacity = '0'; }, 2800);
setTimeout(() => hint.remove(), 3900);
}
function show(worldIdx) {
@ -187,7 +237,10 @@ window.Worlds = (() => {
function _setupButtons() {
document.getElementById('worlds-fab')?.addEventListener('click', _openFab);
document.getElementById('worlds-back')?.addEventListener('click', () => show());
document.getElementById('worlds-back')?.addEventListener('click', () => {
if (_state?.user) show();
else if (window.App) window.App.navigate('welcome');
});
document.querySelectorAll('.wdot').forEach((dot, i) => {
dot.style.pointerEvents = 'auto';
dot.addEventListener('click', () => {
@ -414,7 +467,7 @@ window.Worlds = (() => {
{ icon:'wave-sine', color:'#06B6D4', label:'Gewicht messen', sub:'Aktuelles Gewicht eintragen', page:'health' }] },
{ icon:'target', label:'Übungen', page:'uebungen',
fab:[{ icon:'target', color:'#F59E0B', label:'Training aufzeichnen', sub:'Übung absolviert', page:'uebungen' }] },
{ icon:'list-checks', label:'Trainings-\npläne', page:'trainingsplaene',
{ icon:'list-checks', label:'Trainingspläne', page:'trainingsplaene',
fab:[{ icon:'list-checks', color:'#10B981', label:'Plan erstellen', sub:'Neuen Trainingsplan anlegen', page:'trainingsplaene', action:'openNew' }] },
{ icon:'heart', label:'Adoption', page:'adoption',
fab:[{ icon:'heart', color:'#EF4444', label:'Hund anbieten', sub:'Zur Adoption freigeben', page:'adoption', action:'openNew' }] },
@ -444,7 +497,7 @@ window.Worlds = (() => {
{ icon:'sparkle', label:'Jobs', page:'jobs' },
{ icon:'book-open', label:'Knigge', page:'knigge' },
{ icon:'film-slate', label:'Filme', page:'movies' },
{ icon:'tree-structure', label:'Zucht-\nkartei', page:'zuchthunde', role:'breeder',
{ icon:'tree-structure', label:'Zuchtkartei', page:'zuchthunde', role:'breeder',
fab:[{ icon:'tree-structure', color:'#8B5CF6', label:'Zuchthund eintragen', sub:'Neuen Hund anlegen', page:'zuchthunde', action:'openNew' }] },
{ icon:'notebook', label:'Wurfverw.', page:'litters', role:'breeder',
fab:[{ icon:'notebook', color:'#10B981', label:'Wurf anlegen', sub:'Neuen Wurf eintragen', page:'litters', action:'openNew' }] },
@ -452,12 +505,19 @@ window.Worlds = (() => {
fab:[{ icon:'sparkle', color:'#EC4899', label:'Social-Post', sub:'Beitrag erstellen', page:'social', action:'openNew' }] },
{ icon:'shield-check', label:'Moderation', page:'moderation', role:'mod' },
{ icon:'gear', label:'Admin', page:'admin', role:'admin' },
// ── NEUE FEATURES ────────────────────────────────────────────
{ icon:'fork-knife', label:'Ernährung', page:'ernaehrung',
fab:[{ icon:'fork-knife', color:'#F97316', label:'Futter-Tagebuch', sub:'Mahlzeit oder Futtercheck', page:'ernaehrung' }] },
{ icon:'airplane', label:'Reise', page:'reise' },
{ icon:'smiley', label:'Persönlichkeit', page:'personality' },
];
const _DEFAULT_CONFIG = {
jetzt: ['notes','expenses','erste-hilfe','playdate','chat','wetter','settings'],
hund: ['diary','health','uebungen','trainingsplaene','adoption','sitting','wiki','wurfboerse'],
welt: ['map','forum','friends','walks','poison','recalls','lost','routes','events','jobs','knigge','movies'],
jetzt: ['notes','expenses','erste-hilfe','playdate','chat','wetter','social','moderation','admin'],
hund: ['diary','health','uebungen','trainingsplaene','adoption','sitting','wiki','wurfboerse',
'litters','zuchthunde','ernaehrung','personality'],
welt: ['map','forum','friends','walks','poison','recalls','lost','routes','events',
'jobs','knigge','movies','reise'],
};
// _cfgCache: wird beim Init aus DB geladen, Fallback localStorage → Default
@ -605,10 +665,11 @@ window.Worlds = (() => {
user-select:none;-webkit-tap-highlight-color:transparent;touch-action:none">
${!c.pinned ? `
<button class="wc-remove" data-page="${c.page}" data-zone="${w}"
style="position:absolute;top:-6px;right:-6px;width:18px;height:18px;
border-radius:50%;background:#EF4444;border:none;cursor:pointer;
display:flex;align-items:center;justify-content:center;z-index:2">
<svg class="ph-icon" style="width:9px;height:9px;color:white">
style="position:absolute;top:-8px;right:-8px;width:24px;height:24px;
border-radius:50%;background:#EF4444;border:2px solid rgba(18,22,32,0.9);
cursor:pointer;display:flex;align-items:center;justify-content:center;
z-index:2;box-shadow:0 2px 6px rgba(0,0,0,0.5)">
<svg class="ph-icon" style="width:13px;height:13px;color:white">
<use href="/icons/phosphor.svg#x"></use>
</svg>
</button>` : `
@ -781,11 +842,19 @@ window.Worlds = (() => {
const track = document.getElementById('worlds-track');
if (!track) return;
if (url) {
const img = new Image();
img.onload = () => { track.style.backgroundImage = `url('${url}')`; track.style.backgroundSize = '100% auto'; track.style.backgroundPosition = '0 40%'; track.style.backgroundRepeat = 'no-repeat'; };
img.onerror = () => _applyBgImage(null);
img.src = url;
const toLoad = new Image();
toLoad.onload = () => {
_hasBgPhoto = true;
track.style.backgroundImage = `url('${url}')`;
track.style.backgroundSize = '100% auto';
track.style.backgroundPosition = '0 40%';
track.style.backgroundRepeat = 'no-repeat';
document.getElementById('wh-photo-hint')?.remove();
};
toLoad.onerror = () => _applyBgImage(null);
toLoad.src = url;
} else {
_hasBgPhoto = false;
track.style.backgroundImage = 'linear-gradient(160deg,#1a1f35 0%,#16213e 33%,#1a2535 67%,#0f1921 100%)';
track.style.backgroundSize = '100% 100%';
}
@ -839,26 +908,29 @@ window.Worlds = (() => {
const greet = hour < 5 ? 'Gute Nacht' : hour < 12 ? 'Guten Morgen' : hour < 18 ? 'Hallo' : 'Guten Abend';
const firstName = user?.name?.split(' ')[0] || '';
const dayStr = new Date().toLocaleDateString('de-DE', { weekday:'long', day:'numeric', month:'long' });
const stale = isOffline && staleMin > 5
const stale = isOffline && staleMin > 5
? `<span style="font-size:9px;opacity:0.5;margin-left:6px">· Offline</span>` : '';
const weatherLine = w
? `${Math.round(w.temp_c)}° ${_esc(w.desc?.split(' ')[0] || '')} · ${Math.round(w.wind_kmh || 0)} km/h · ${w.precip_prob || 0}% Regen`
: '';
// Streak für 3er-Chip-Zeile
let streakVal = '—', streakCol = 'rgba(255,255,255,0.4)';
if (user && dog) {
try {
const sr = await _cachedGet(`streak_${dog.id}`, `/streak/${dog.id}`);
const s = sr.data;
const streak = s?.current_streak || 0;
const trainedToday = s?.last_training_date === new Date().toISOString().slice(0,10);
streakCol = trainedToday ? '#10B981' : (streak > 0 ? '#F59E0B' : 'rgba(255,255,255,0.4)');
streakVal = streak > 0
? (trainedToday ? `${streak} Tage` : `🔥 ${streak} Tage`)
: (trainedToday ? '✓ Heute' : 'Heute starten');
} catch {}
// Gassi-Score aus Wetterdaten berechnen
function _calcGassiScore(wd) {
if (!wd) return null;
let s = 10;
const t = wd.temp_c ?? 20, p = wd.precip_prob ?? 0, wind = wd.wind_kmh ?? 0;
if (t > 30) s -= 3; else if (t > 25) s -= 1; else if (t < 0) s -= 3; else if (t < 5) s -= 1;
if (p > 70) s -= 3; else if (p > 40) s -= 2; else if (p > 20) s -= 1;
if (wind > 60) s -= 2; else if (wind > 40) s -= 1;
if (wd.thunderstorm) s -= 3;
return Math.max(1, Math.min(10, s));
}
const gassiScore = _calcGassiScore(w);
const gassiColor = gassiScore >= 8 ? '#10B981' : gassiScore >= 5 ? '#F59E0B' : '#EF4444';
const weatherEmoji = !w ? '🌤️'
: w.thunderstorm ? '⛈️'
: (w.precip_prob ?? 0) > 70 ? '🌧️'
: (w.precip_prob ?? 0) > 30 ? '🌦️'
: (w.temp_c ?? 20) > 28 ? '☀️🔥'
: (w.temp_c ?? 20) < 2 ? '🌨️'
: '☀️';
// Alert-Reminder
const alertHtml = alertList.slice(0,1).map(a => `
@ -901,7 +973,7 @@ window.Worlds = (() => {
<div class="world-info-title">
${_esc(greet)}${firstName ? `, <span style="color:var(--c-primary)">${_esc(firstName)}</span>` : ''}${stale}
</div>
<div class="world-info-sub">${_esc(dayStr)}${weatherLine ? ' · ' + weatherLine : ''}${totalKm != null ? ' · ' + totalKm + ' km' : ''}</div>
<div class="world-info-sub">${_esc(dayStr)}</div>
</div>
${user ? userAvatarHtml : ''}
</div>
@ -909,17 +981,25 @@ window.Worlds = (() => {
${alertHtml}
${user && dog ? `
<div class="wj-chip-row">
<div class="wj-chip" data-wnav="uebungen">
<svg class="ph-icon" style="width:1.2rem;height:1.2rem;color:${streakCol}">
<use href="/icons/phosphor.svg#target"></use></svg>
<span class="wj-chip-label">Streak</span>
<span class="wj-chip-val">${streakVal}</span>
<div class="wj-chip" data-wnav="wetter" style="${gassiScore ? `border-color:${gassiColor}44;background:${gassiColor}12;` : ''}">
<div style="display:flex;align-items:center;gap:6px;width:100%">
<span style="font-size:1.4rem;line-height:1;flex-shrink:0">${weatherEmoji}</span>
<div style="flex:1;min-width:0">
<div style="font-size:9px;color:rgba(255,255,255,0.55);font-weight:600;letter-spacing:.05em;text-transform:uppercase">Gassi-Score</div>
<div style="display:flex;align-items:baseline;gap:3px;margin-top:1px">
<span style="font-size:1.25rem;font-weight:800;color:${gassiColor};line-height:1">${gassiScore ?? '—'}</span>
${gassiScore ? `<span style="font-size:var(--text-xs);color:rgba(255,255,255,0.4);font-weight:600">/10</span>` : ''}
</div>
${w ? `<div style="font-size:9px;color:rgba(255,255,255,0.5);margin-top:1px">${Math.round(w.temp_c ?? 0)}° · ${w.precip_prob ?? 0}% Regen</div>` : ''}
</div>
</div>
</div>
<div class="wj-chip" data-wnav="routes">
<svg class="ph-icon" style="width:1.2rem;height:1.2rem;color:var(--c-primary)">
<use href="/icons/phosphor.svg#path"></use></svg>
<span class="wj-chip-label">Gassirunde</span>
<span class="wj-chip-val" id="wj-route-val"></span>
${totalKm != null ? `<span style="font-size:9px;color:rgba(255,255,255,0.4);margin-top:1px">∑ ${totalKm} km</span>` : ''}
</div>
<div class="wj-chip" data-wnav="uebungen">
<svg class="ph-icon" style="width:1.2rem;height:1.2rem;color:var(--c-primary)">
@ -1018,13 +1098,41 @@ window.Worlds = (() => {
const dogs = dogsRes.data || [];
if (!dogs.length) {
const features = [
{ icon:'book-open', color:'#8B5CF6', title:'Tagebuch', sub:'Fotos & Erlebnisse' },
{ icon:'heartbeat', color:'#EF4444', title:'Gesundheit', sub:'Impfungen & Gewicht' },
{ icon:'target', color:'#F59E0B', title:'Training', sub:'104 Übungen' },
{ icon:'books', color:'#10B981', title:'Wiki', sub:'Alle Rassen' },
{ icon:'paw-print', color:'#3B82F6', title:'Gassi', sub:'Routen & GPS' },
{ icon:'currency-eur',color:'#06B6D4',title:'Ausgaben', sub:'Budget im Blick' },
];
el.innerHTML = `
<div class="world-info-card" style="text-align:center">
<div style="font-size:4rem;margin-bottom:12px">🐶</div>
<div class="world-info-title">Noch kein Hund angelegt</div>
<div class="world-info-sub" style="margin-bottom:20px">Erstelle das Profil deines Hundes</div>
<button class="btn btn-primary" onclick="Worlds.navigateTo('dog-profile')">Hund anlegen</button>
<div class="world-top">
<div class="world-info-card" style="text-align:center">
<div style="font-size:3.2rem;margin-bottom:10px">🐶</div>
<div class="world-info-title">Dein Hund wartet!</div>
<div class="world-info-sub" style="margin-bottom:16px">
Lege ein Profil an und schalte alle Features frei
</div>
<button class="btn btn-primary" style="width:100%" onclick="Worlds.navigateTo('dog-profile')">
Hund anlegen
</button>
</div>
</div>
<div class="world-bottom">
<div class="world-section-label">Was dich erwartet</div>
<div class="world-chips-grid">
${features.map(f => `
<div class="world-chip" style="opacity:0.7;cursor:default">
<svg class="ph-icon" style="width:1.4rem;height:1.4rem;color:${f.color}">
<use href="/icons/phosphor.svg#${f.icon}"></use>
</svg>
<span class="world-chip-label">${f.title}</span>
</div>
`).join('')}
</div>
</div>`;
el.querySelectorAll('[data-wnav]').forEach(e => e.addEventListener('click', () => navigateTo(e.dataset.wnav)));
return;
}
@ -1093,6 +1201,25 @@ window.Worlds = (() => {
</div>
</div>
<div class="world-bottom">
${!_hasBgPhoto ? `
<div id="wh-photo-hint" data-wnav="diary"
style="background:rgba(0,0,0,0.32);backdrop-filter:blur(12px);
-webkit-backdrop-filter:blur(12px);border:1px solid rgba(255,255,255,0.12);
border-radius:16px;padding:11px 14px;display:flex;align-items:center;
gap:10px;cursor:pointer;color:white;flex-shrink:0">
<svg class="ph-icon" style="width:1.1rem;height:1.1rem;color:rgba(196,132,58,0.9);flex-shrink:0">
<use href="/icons/phosphor.svg#camera"></use>
</svg>
<div>
<div style="font-size:var(--text-xs);font-weight:700;color:rgba(255,255,255,0.85)">
Hintergrund-Foto hinzufügen
</div>
<div style="font-size:10px;color:rgba(255,255,255,0.45);margin-top:1px">
Tagebuchfotos erscheinen hier als Panorama
</div>
</div>
</div>
` : ''}
<div class="world-section-label">Alles über ${_esc(dog.name)}</div>
<div class="world-chips-grid">
${chips.map(c => _chip(c.icon, c.label, c.page)).join('')}