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

@ -325,6 +325,18 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
os.makedirs(MEDIA_DIR, exist_ok=True) os.makedirs(MEDIA_DIR, exist_ok=True)
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
APP_VER = "715" # muss mit APP_VER in app.js übereinstimmen
@app.get("/api/version")
async def app_version():
"""Aktuelle Frontend-Version — wird beim App-Start gecheckt."""
return Response(
content=f'{{"version":"{APP_VER}"}}',
media_type="application/json",
headers={"Cache-Control": "no-store"},
)
@app.get("/stats/script.js") @app.get("/stats/script.js")
async def umami_script_proxy(): async def umami_script_proxy():
async with httpx.AsyncClient(timeout=10) as client: async with httpx.AsyncClient(timeout=10) as client:

View file

@ -178,6 +178,17 @@ def generate_preview(data: bytes, ext: str) -> bytes | None:
return None return None
def get_image_size(data: bytes) -> tuple[int, int] | None:
"""Gibt (width, height) eines Bildes zurück, oder None bei Fehler."""
try:
from PIL import Image, ImageOps
img = Image.open(io.BytesIO(data))
img = ImageOps.exif_transpose(img)
return img.size # (width, height)
except Exception:
return None
def preview_url_from(url: str | None) -> str | None: def preview_url_from(url: str | None) -> str | None:
"""Leitet die Preview-URL aus der Original-URL ab (fügt _preview vor Extension ein). """Leitet die Preview-URL aus der Original-URL ab (fügt _preview vor Extension ein).
Gibt None für Videos, PDFs, bereits generierte Previews und leere URLs zurück.""" Gibt None für Videos, PDFs, bereits generierte Previews und leere URLs zurück."""

View file

@ -203,11 +203,11 @@ def check_and_award(user_id: int, conn):
"SELECT current_streak FROM users WHERE id=?", (user_id,) "SELECT current_streak FROM users WHERE id=?", (user_id,)
).fetchone() ).fetchone()
# Wetter-Tapferkeit: Diary-Einträge bei schlechtem Wetter # Wetter-Tapferkeit: Diary-Einträge bei schlechtem Wetter (über Dog-Join)
wetter_row = conn.execute(""" wetter_row = conn.execute("""
SELECT COUNT(*) AS cnt FROM diary d SELECT COUNT(*) AS cnt FROM diary d
LEFT JOIN diary_dogs dd ON dd.diary_id = d.id JOIN dogs dog ON dog.id = d.dog_id
WHERE d.user_id = ? WHERE dog.user_id = ?
AND d.weather_json IS NOT NULL AND d.weather_json IS NOT NULL
AND ( AND (
CAST(json_extract(d.weather_json, '$.precip_prob') AS INTEGER) > 60 CAST(json_extract(d.weather_json, '$.precip_prob') AS INTEGER) > 60
@ -216,23 +216,28 @@ def check_and_award(user_id: int, conn):
) )
""", (user_id,)).fetchone() """, (user_id,)).fetchone()
# Jahreszeiten: Anzahl Jahreszeiten mit mind. 5 Diary-Einträgen # Jahreszeiten: Anzahl Jahreszeiten mit mind. 5 Diary-Einträgen (über Dog-Join)
jahreszeiten_row = conn.execute(""" jahreszeiten_row = conn.execute("""
SELECT SELECT
(CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (3,4,5)) >= 5 THEN 1 ELSE 0 END) + (CASE WHEN (SELECT COUNT(*) FROM diary d2 JOIN dogs g2 ON g2.id=d2.dog_id
(CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (6,7,8)) >= 5 THEN 1 ELSE 0 END) + WHERE g2.user_id=? AND CAST(strftime('%m', d2.datum) AS INTEGER) IN (3,4,5)) >= 5 THEN 1 ELSE 0 END) +
(CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (9,10,11)) >= 5 THEN 1 ELSE 0 END) + (CASE WHEN (SELECT COUNT(*) FROM diary d2 JOIN dogs g2 ON g2.id=d2.dog_id
(CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (12,1,2)) >= 5 THEN 1 ELSE 0 END) WHERE g2.user_id=? AND CAST(strftime('%m', d2.datum) AS INTEGER) IN (6,7,8)) >= 5 THEN 1 ELSE 0 END) +
(CASE WHEN (SELECT COUNT(*) FROM diary d2 JOIN dogs g2 ON g2.id=d2.dog_id
WHERE g2.user_id=? AND CAST(strftime('%m', d2.datum) AS INTEGER) IN (9,10,11)) >= 5 THEN 1 ELSE 0 END) +
(CASE WHEN (SELECT COUNT(*) FROM diary d2 JOIN dogs g2 ON g2.id=d2.dog_id
WHERE g2.user_id=? AND CAST(strftime('%m', d2.datum) AS INTEGER) IN (12,1,2)) >= 5 THEN 1 ELSE 0 END)
AS jahreszeiten_score AS jahreszeiten_score
FROM (SELECT 1) FROM (SELECT 1)
""", (user_id, user_id, user_id, user_id)).fetchone() """, (user_id, user_id, user_id, user_id)).fetchone()
# Schnee: Diary-Einträge bei Schnee (weathercode 71-77) # Schnee: Diary-Einträge bei Schnee (weathercode 71-77, über Dog-Join)
schnee_row = conn.execute(""" schnee_row = conn.execute("""
SELECT COUNT(*) AS cnt FROM diary SELECT COUNT(*) AS cnt FROM diary d
WHERE user_id = ? JOIN dogs dog ON dog.id = d.dog_id
AND weather_json IS NOT NULL WHERE dog.user_id = ?
AND CAST(json_extract(weather_json, '$.weathercode') AS INTEGER) BETWEEN 71 AND 77 AND d.weather_json IS NOT NULL
AND CAST(json_extract(d.weather_json, '$.weathercode') AS INTEGER) BETWEEN 71 AND 77
""", (user_id,)).fetchone() """, (user_id,)).fetchone()
metrics = { metrics = {

View file

@ -280,9 +280,14 @@ class UserPoiIn(BaseModel):
ALLOWED_TYPES = { ALLOWED_TYPES = {
'waste_basket', 'drinking_water', 'dog_park', 'waste_basket', 'drinking_water', 'dog_park',
'giftkoeder', # Giftköder (exklusiv, kein Kombi) 'giftkoeder', # Giftköder (exklusiv, kein Kombi)
'gefahr', # Allgemeine Gefahr / Hinweis
'freilauf', # Freilauffläche
'restaurant', # Hundefreundliches Restaurant / Café
'shop', # Hundefreundlicher Shop
'tierarzt', # Tierarzt / Tierklinik
'hundeschule', # Hundeschule / Trainer
'kotbeutel', # Kotbeutelspender 'kotbeutel', # Kotbeutelspender
'bank', # Sitzbank 'bank', # Sitzbank
'gefahr', # Allgemeine Gefahr / Hinweis
'parkplatz', # Hundefreundlicher Parkplatz 'parkplatz', # Hundefreundlicher Parkplatz
'treffpunkt', # Treffpunkt für Hundehalter 'treffpunkt', # Treffpunkt für Hundehalter
'sonstiges', 'sonstiges',

View file

@ -43,7 +43,8 @@ async def weather_records(user=Depends(get_current_user)):
rows = conn.execute(""" rows = conn.execute("""
SELECT d.datum, d.weather_json, d.titel SELECT d.datum, d.weather_json, d.titel
FROM diary d FROM diary d
WHERE d.user_id = ? AND d.weather_json IS NOT NULL JOIN dogs dog ON dog.id = d.dog_id
WHERE dog.user_id = ? AND d.weather_json IS NOT NULL
ORDER BY d.datum ASC ORDER BY d.datum ASC
""", (uid,)).fetchall() """, (uid,)).fetchall()

View file

@ -1,6 +1,6 @@
"""BAN YARO — Widget-Snapshot + Tagesspruch Endpoints""" """BAN YARO — Widget-Snapshot + Tagesspruch Endpoints"""
import json, random import json
from datetime import date from datetime import date
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from typing import Optional from typing import Optional
@ -59,7 +59,8 @@ async def widget_snapshot(user=Depends(get_current_user)):
(dog_id,) (dog_id,)
).fetchall() ).fetchall()
random_photo = dict(random.choice(photos)) if photos else None day_num = (date.today() - date(2024, 1, 1)).days
random_photo = dict(photos[day_num % len(photos)]) if photos else None
# Anzahl überfälliger Erinnerungen # Anzahl überfälliger Erinnerungen
overdue = conn.execute( overdue = conn.execute(

View file

@ -8061,28 +8061,35 @@ svg.empty-state-icon {
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
border-radius: 16px; border-radius: 16px;
padding: 14px 6px 11px; padding: 12px 6px;
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 7px; justify-content: center;
gap: 6px;
color: white; color: white;
transition: background 0.12s, transform 0.1s; transition: background 0.12s, transform 0.1s;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
user-select: none; user-select: none;
min-height: 80px; /* alle Chips gleich hoch */
} }
.world-chip:active { .world-chip:active {
background: rgba(0, 0, 0, 0.6); background: rgba(0, 0, 0, 0.6);
transform: scale(0.93); transform: scale(0.93);
} }
.world-chip svg { color: white; } .world-chip svg { color: white; flex-shrink: 0; }
.world-chip-label { .world-chip-label {
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
color: rgba(255, 255, 255, 0.9); color: rgba(255, 255, 255, 0.9);
line-height: 1.2; line-height: 1.2;
max-height: 2.4em; /* max. 2 Zeilen */
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
} }
/* Chip-Umrandung je Welt */ /* Chip-Umrandung je Welt */

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View file

@ -93,9 +93,9 @@
</script> </script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung --> <!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=700"> <link rel="stylesheet" href="/css/design-system.css?v=709">
<link rel="stylesheet" href="/css/layout.css?v=700"> <link rel="stylesheet" href="/css/layout.css?v=709">
<link rel="stylesheet" href="/css/components.css?v=700"> <link rel="stylesheet" href="/css/components.css?v=709">
</head> </head>
<body> <body>
@ -574,7 +574,7 @@
<script src="/js/api.js?v=94"></script> <script src="/js/api.js?v=94"></script>
<script src="/js/ui.js?v=94"></script> <script src="/js/ui.js?v=94"></script>
<script src="/js/app.js?v=94"></script> <script src="/js/app.js?v=94"></script>
<script src="/js/worlds.js?v=700"></script> <script src="/js/worlds.js?v=715"></script>
<!-- Feature-Seiten werden lazy geladen --> <!-- Feature-Seiten werden lazy geladen -->
@ -676,5 +676,6 @@
} }
</script> </script>
</body> </body>
</html> </html>

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. 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 APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app'; const IS_STAGING = location.hostname === 'staging.banyaro.app';
@ -484,6 +484,9 @@ const App = (() => {
navigate('onboarding'); navigate('onboarding');
} }
// Drei Welten nach Login starten (falls noch nicht initialisiert)
if (window.Worlds) window.Worlds.init(state);
_showVerifyBanner(); _showVerifyBanner();
_updateNotifBadge(); _updateNotifBadge();
_updateChatBadge(); _updateChatBadge();
@ -559,7 +562,8 @@ const App = (() => {
_updateHeaderUserBtn(false); _updateHeaderUserBtn(false);
// Nicht eingeloggte User immer zur Welcome-Seite window.Worlds?.hide();
document.getElementById('worlds-back')?.classList.remove('worlds-back-visible');
navigate('welcome', false); navigate('welcome', false);
} }
@ -855,11 +859,8 @@ const App = (() => {
} }
const startPage = (hashPage && pages[hashPage]) ? hashPage : 'welcome'; 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); navigate(state.user ? startPage : 'welcome', false, hashParams);
if (window.Worlds && state.user) window.Worlds.init(state);
// Drei Welten nach initialer Navigation starten (damit hide() in navigate() sie nicht gleich killt)
if (window.Worlds) window.Worlds.init(state);
} }
async function _handleInvite(token) { async function _handleInvite(token) {

View file

@ -125,47 +125,64 @@ window.Page_ernaehrung = (() => {
el.innerHTML = ` el.innerHTML = `
<div style="padding:var(--space-4) 0"> <div style="padding:var(--space-4) 0">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-4)"> <style>
Berechne den täglichen Kalorienbedarf deines Hundes. .ern-pill-group { display:flex; gap:8px; flex-wrap:wrap; }
</p> .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"> <!-- Gewicht + Alter nebeneinander -->
<label class="by-label">Gewicht (kg)</label> <div class="ern-input-row">
<input id="ern-gewicht" type="number" step="0.1" min="0.5" max="100" <div class="ern-field">
class="by-input" value="${_esc(gewichtDefault)}" placeholder="z. B. 15"> <label> Gewicht (kg)</label>
</div> <input id="ern-gewicht" type="number" step="0.1" min="0.5" max="100"
value="${_esc(gewichtDefault)}" placeholder="15">
<div class="by-form-group"> </div>
<label class="by-label">Alter (Jahre)</label> <div class="ern-field">
<input id="ern-alter" type="number" step="0.5" min="0" max="25" <label>🎂 Alter (Jahre)</label>
class="by-input" value="${_esc(alterDefault)}" placeholder="z. B. 3"> <input id="ern-alter" type="number" step="0.5" min="0" max="25"
</div> value="${_esc(alterDefault)}" placeholder="3">
<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>
</div> </div>
</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> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#calculator"></use></svg>
Berechnen Kalorienbedarf berechnen
</button> </button>
<div id="ern-rechner-result" style="display:none;margin-top:var(--space-5)"></div> <div id="ern-rechner-result" style="display:none;margin-top:var(--space-5)"></div>
@ -208,13 +225,28 @@ window.Page_ernaehrung = (() => {
</div> </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)); el.querySelector('#ern-rechner-btn').addEventListener('click', () => _berechne(el));
} }
function _berechne(el) { function _berechne(el) {
const gewicht = parseFloat(el.querySelector('#ern-gewicht').value); const gewicht = parseFloat(el.querySelector('#ern-gewicht').value);
const aktivitaet = el.querySelector('#ern-aktivitaet').value; const aktivitaet = el.querySelector('[data-akt].active')?.dataset.akt || 'normal';
const kastriert = el.querySelector('input[name="ern-kastriert"]:checked')?.value === 'ja'; const kastriert = el.querySelector('[data-kas].active')?.dataset.kas === 'ja';
if (!gewicht || gewicht < 0.5) { if (!gewicht || gewicht < 0.5) {
UI.toast.warning('Bitte ein gültiges Gewicht eingeben.'); UI.toast.warning('Bitte ein gültiges Gewicht eingeben.');

View file

@ -5,15 +5,26 @@
window.Page_expenses = (() => { window.Page_expenses = (() => {
let _container = null; let _container = null;
let _appState = null; let _appState = null;
let _tab = 'uebersicht'; let _tab = 'uebersicht';
let _selectedDogId = null;
// Cache // Cache
let _summary = null; let _summary = null;
let _entries = []; let _entries = [];
let _statsData = null; 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 = [ const TABS = [
{ id: 'uebersicht', label: 'Übersicht', icon: 'house-line' }, { id: 'uebersicht', label: 'Übersicht', icon: 'house-line' },
{ id: 'eintraege', label: 'Ausgaben', icon: 'currency-eur' }, { id: 'eintraege', label: 'Ausgaben', icon: 'currency-eur' },
@ -38,11 +49,10 @@ window.Page_expenses = (() => {
// LIFECYCLE // LIFECYCLE
// ---------------------------------------------------------- // ----------------------------------------------------------
async function init(container, appState) { async function init(container, appState) {
_container = container; _container = container;
_appState = appState; _appState = appState;
_summary = null; _selectedDogId = null;
_entries = []; _clearCache();
_statsData = null;
_render(); _render();
} }
@ -56,6 +66,16 @@ window.Page_expenses = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// SHELL // 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() { function _render() {
_container.innerHTML = ` _container.innerHTML = `
<div class="by-tabs exp-tabs" id="exp-tabs"> <div class="by-tabs exp-tabs" id="exp-tabs">
@ -65,6 +85,7 @@ window.Page_expenses = (() => {
</button> </button>
`).join('')} `).join('')}
</div> </div>
${_dogSelectorHtml()}
<div id="exp-content"></div> <div id="exp-content"></div>
<button class="exp-fab" id="exp-fab" title="Neue Ausgabe"> <button class="exp-fab" id="exp-fab" title="Neue Ausgabe">
${UI.icon('plus')} ${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') _container.querySelector('#exp-fab')
?.addEventListener('click', () => _showForm(null)); ?.addEventListener('click', () => _showForm(null));
@ -111,7 +143,7 @@ window.Page_expenses = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
async function _renderUebersicht(el) { async function _renderUebersicht(el) {
if (!_summary) { if (!_summary) {
_summary = await API.get('/expenses/summary'); _summary = await API.get('/expenses/summary' + _dogParam());
} }
const s = _summary; const s = _summary;
@ -120,14 +152,20 @@ window.Page_expenses = (() => {
const trendHtml = _trendHtml(letzteMonat); const trendHtml = _trendHtml(letzteMonat);
const kacheln = KATEGORIEN.map(k => { 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 ` 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}"> <div class="exp-kachel-icon" style="background:${k.color}20;color:${k.color}">
${UI.icon(k.icon)} ${UI.icon(k.icon)}
</div> </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> <div class="exp-kachel-label">${k.label}</div>
${monatLine}
<div class="exp-kachel-add">${UI.icon('plus')} eintragen</div>
</div>`; </div>`;
}).join(''); }).join('');
@ -146,11 +184,15 @@ window.Page_expenses = (() => {
${verlauf} ${verlauf}
<div style="height:80px"></div> <div style="height:80px"></div>
`; `;
el.querySelectorAll('.exp-kachel[data-kat]').forEach(k => {
k.addEventListener('click', () => _showForm(null, k.dataset.kat));
});
} }
async function _getLetzteMonateData() { async function _getLetzteMonateData() {
if (!_entries.length) { if (!_entries.length) {
_entries = await API.get('/expenses?limit=500'); _entries = await API.get('/expenses?limit=500' + _dogParamAnd());
} }
const monatMap = {}; const monatMap = {};
_entries.forEach(e => { _entries.forEach(e => {
@ -208,7 +250,7 @@ window.Page_expenses = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
async function _renderEintraege(el) { async function _renderEintraege(el) {
if (!_entries.length) { if (!_entries.length) {
_entries = await API.get('/expenses?limit=500'); _entries = await API.get('/expenses?limit=500' + _dogParamAnd());
} }
if (!_entries.length) { if (!_entries.length) {
@ -321,6 +363,7 @@ window.Page_expenses = (() => {
async function _renderDauerauftraege(el) { async function _renderDauerauftraege(el) {
let recurring = []; let recurring = [];
try { recurring = await API.get('/expenses/recurring'); } catch { /* leer */ } 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 cards = recurring.map(r => {
const k = _kat(r.kategorie); const k = _kat(r.kategorie);
@ -481,10 +524,10 @@ window.Page_expenses = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
async function _renderStatistik(el) { async function _renderStatistik(el) {
if (!_summary) { if (!_summary) {
_summary = await API.get('/expenses/summary'); _summary = await API.get('/expenses/summary' + _dogParam());
} }
if (!_entries.length) { if (!_entries.length) {
_entries = await API.get('/expenses?limit=500'); _entries = await API.get('/expenses?limit=500' + _dogParamAnd());
} }
const s = _summary; const s = _summary;
@ -637,14 +680,15 @@ window.Page_expenses = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// FORMULAR — Neu / Bearbeiten // FORMULAR — Neu / Bearbeiten
// ---------------------------------------------------------- // ----------------------------------------------------------
function _showForm(entry) { function _showForm(entry, preKat) {
const isEdit = !!entry; const isEdit = !!entry;
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
const formId = 'exp-form'; 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 => 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(''); ).join('');
// Kategorie-Kacheln statt Dropdown // Kategorie-Kacheln statt Dropdown
@ -730,6 +774,9 @@ window.Page_expenses = (() => {
const modal = UI.modal.open({ title: isEdit ? 'Ausgabe bearbeiten' : 'Neue Ausgabe', body, footer }); 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 // Kategorie-Kacheln interaktiv
modal.querySelectorAll('.exp-kat-tile').forEach(tile => { modal.querySelectorAll('.exp-kat-tile').forEach(tile => {
tile.addEventListener('click', () => { tile.addEventListener('click', () => {

View file

@ -30,7 +30,7 @@ window.Page_jobs = (() => {
} }
_container.innerHTML = ` _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 --> <!-- Header -->
<div style="text-align:center;margin-bottom:var(--space-6)"> <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> value="${u ? _esc(u.email || '') : ''}" placeholder="deine@email.de" required>
</div> </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"> <div class="form-group" style="margin:0">
<label class="form-label">Hunde-Name</label> <label class="form-label">Hunde-Name</label>
<input class="form-control" type="text" name="dog_name" placeholder="z. B. Bella"> <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) // Einzelne Basis-Typen — Mehrfachauswahl möglich (außer giftkoeder = exklusiv)
const PIN_TYPES = [ 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: '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: 'gefahr', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>', label: 'Gefahr', color: '#F59E0B' },
{ type: 'kotbeutel', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bag"></use></svg>', label: 'Kotbeutel', color: '#84A98C' }, { type: 'freilauf', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>', label: 'Freilauf', color: '#22C55E' },
{ type: 'bank', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#park"></use></svg>', label: 'Sitzbank', color: '#92400E' }, { type: 'dog_park', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>', label: 'Hundewiese', color: '#15803D' },
{ type: 'drinking_water',icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#drop"></use></svg>', label: 'Wasserstelle',color: '#0EA5E9' }, { type: 'restaurant', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#fork-knife"></use></svg>', label: 'Restaurant', color: '#F97316' },
{ type: 'dog_park', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>', label: 'Hundewiese', color: '#15803D' }, { type: 'shop', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shopping-cart"></use></svg>', label: 'Shop', color: '#3B82F6' },
{ type: 'parkplatz', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>', label: 'Parkplatz', color: '#2563EB' }, { type: 'tierarzt', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>', label: 'Tierarzt', color: '#EF4444' },
{ type: 'treffpunkt', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg>', label: 'Treffpunkt', color: '#7C3AED' }, { type: 'hundeschule', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#graduation-cap"></use></svg>', label: 'Hundeschule', color: '#8B5CF6' },
{ type: 'sonstiges', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>', label: 'Sonstiges', color: '#F59E0B' }, { 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) { function _confirmPlacement(latlng) {

View file

@ -12,7 +12,6 @@ window.Page_reise = (() => {
const TABS = [ const TABS = [
{ key: 'checkliste', label: 'Checkliste', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#check-square"></use></svg>' }, { 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: '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 = [ const CHECKLIST = [
@ -94,7 +93,15 @@ window.Page_reise = (() => {
{ icon: 'heartbeat', text: 'Bewusstlosigkeit: Atemwege freihalten, stabile Seitenlage, 112 rufen' }, { 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 // Helpers
@ -177,37 +184,79 @@ window.Page_reise = (() => {
// ------------------------------------------------------------------ // ------------------------------------------------------------------
function _renderCheckliste(el) { function _renderCheckliste(el) {
const checked = _loadChecked(); const checked = _loadChecked();
const custom = _loadCustom();
const hidden = _loadHidden();
const totalItems = CHECKLIST.reduce((s, c) => s + c.items.length, 0); // Alle sichtbaren Items zählen
const doneItems = Object.values(checked).filter(Boolean).length; let totalItems = 0, doneItems = 0;
const pct = totalItems > 0 ? Math.round((doneItems / totalItems) * 100) : 0; CHECKLIST.forEach(cat => {
cat.items.forEach((_, idx) => {
const progressBar = ` if (!hidden[_itemKey(cat.key, idx)]) {
<div style="margin-bottom:var(--space-5)"> totalItems++;
<div style="display:flex;justify-content:space-between;align-items:center; if (checked[_itemKey(cat.key, idx)]) doneItems++;
margin-bottom:var(--space-2)"> }
<span style="font-size:var(--text-sm);color:var(--c-text-secondary)"> });
${doneItems} von ${totalItems} erledigt (custom[cat.key] || []).forEach((_, i) => {
</span> totalItems++;
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold); if (checked[`${cat.key}__custom__${i}`]) doneItems++;
color:var(--c-primary)">${pct}%</span> });
</div> });
<div style="height:8px;background:var(--c-surface-2);border-radius:var(--radius-full);overflow:hidden"> const pct = totalItems > 0 ? Math.round((doneItems / totalItems) * 100) : 0;
<div style="height:100%;width:${pct}%;background:var(--c-primary);
border-radius:var(--radius-full);transition:width .3s ease"></div>
</div>
</div>`;
const cats = CHECKLIST.map(cat => { 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 key = _itemKey(cat.key, idx);
const done = !!checked[key]; 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' : ''}> <input type="checkbox" class="reise-cb" data-key="${_esc(key)}" ${done ? 'checked' : ''}>
<span>${_esc(item)}</span> <span>${_esc(item)}</span>
</label>`; </label>`;
}).join(''); }).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)"> 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); <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)"> 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> <span style="font-weight:var(--weight-semibold)">${_esc(cat.label)}</span>
</div> </div>
<div style="padding:var(--space-2) var(--space-4)"> <div style="padding:var(--space-2) var(--space-4)">
${rows} ${stdRows}${customRows}${addRow}
</div> </div>
</div>`; </div>`;
}).join(''); }).join('');
el.innerHTML = ` 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> <style>
.reise-check-row { .reise-check-row {
display:flex;align-items:flex-start;gap:var(--space-3); display:flex;align-items:flex-start;gap:var(--space-3);
@ -245,6 +286,32 @@ window.Page_reise = (() => {
accent-color:var(--c-primary);cursor:pointer; accent-color:var(--c-primary);cursor:pointer;
} }
</style> </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 // Checkbox events
@ -254,7 +321,55 @@ window.Page_reise = (() => {
const cur = _loadChecked(); const cur = _loadChecked();
cur[key] = cb.checked; cur[key] = cb.checked;
_saveChecked(cur); _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; _mode = mode;
_container.innerHTML = ` _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 --> <!-- Logo -->
<div style="text-align:center;margin-bottom:var(--space-6)"> <div style="text-align:center;margin-bottom:var(--space-6)">
<img src="/icons/icon-180.png" alt="Ban Yaro" <img src="/icons/icon-180.png" alt="Ban Yaro"
style="width:72px;height:72px;border-radius:var(--radius-lg); style="width:72px;height:72px;border-radius:var(--radius-lg);
margin-bottom:var(--space-3)"> display:block;margin:0 auto var(--space-3)">
<h1 style="font-size:var(--text-2xl);font-weight:700;margin:0">Ban Yaro</h1> <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"> <p style="color:var(--c-text-secondary);margin:var(--space-1) 0 0;text-align:center">
Alles rund um deinen Hund Alles rund um deinen Hund
</p> </p>
</div> </div>

View file

@ -497,9 +497,16 @@ window.Page_welcome = (() => {
API.dogs.welcomeDashboard(dog.id).then(dash => { API.dogs.welcomeDashboard(dog.id).then(dash => {
_updateHeroFromDash(dash, dog); _updateHeroFromDash(dash, dog);
_updateChipsFromDash(dash); _updateChipsFromDash(dash);
_tryRouteChip(dash); // nach Chips-Update: ggf. Gassirunden-Vorschlag einfügen _tryRouteChip(dash);
}).catch(() => { /* Skeleton bleibt sichtbar */ }); }).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 // Streak-Widget asynchron laden
_loadStreakWidget(dog.id); _loadStreakWidget(dog.id);
} }

View file

@ -112,22 +112,82 @@ window.Page_wetter = (() => {
function _showLocationError() { function _showLocationError() {
const body = _container.querySelector('#wttr-body'); const body = _container.querySelector('#wttr-body');
if (!body) return; if (!body) return;
const isLoggedIn = !!_appState?.user;
body.innerHTML = ` body.innerHTML = `
<div style="text-align:center;padding:var(--space-10) var(--space-4)"> <div style="max-width:420px;margin:0 auto;padding:var(--space-6) 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> <!-- Hero -->
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-5);max-width:300px;margin-inline:auto"> <div style="text-align:center;margin-bottom:var(--space-6)">
Bitte erlaube den Zugriff auf deinen Standort, um die Wettervorhersage zu laden. <div style="font-size:4rem;line-height:1;margin-bottom:var(--space-2)">🌤🐾</div>
</p> <h2 style="font-size:var(--text-xl);font-weight:800;margin:0 0 var(--space-2)">
<button class="btn btn-primary" id="wttr-btn-retry"> Das Gassi-Wetter wartet auf dich
${UI.icon('map-pin')} Nochmal versuchen </h2>
</button> <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> </div>
`; `;
body.querySelector('#wttr-btn-retry')?.addEventListener('click', () => { body.querySelector('#wttr-btn-retry')?.addEventListener('click', () => {
_renderShell(); _renderShell();
_tryAutoLocate(); _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) { function _recordCard(emoji, title, value, subtitle, color) {
return ` return `
<div style="background:var(--c-bg-card);border:1px solid var(--c-border); <div style="background:${color}10;border:1px solid ${color}33;
border-radius:var(--radius);padding:var(--space-3) var(--space-3); border-radius:var(--radius);padding:var(--space-3);
display:flex;flex-direction:column;gap:2px"> display:flex;flex-direction:column;gap:3px">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary); <div style="font-size:10px;color:var(--c-text-secondary);
display:flex;align-items:center;gap:4px;font-weight:600"> display:flex;align-items:center;gap:3px;font-weight:700;
text-transform:uppercase;letter-spacing:.04em">
<span>${emoji}</span> <span>${emoji}</span>
<span>${_esc(title)}</span> <span>${_esc(title)}</span>
</div> </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)} ${_esc(value)}
</div> </div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary); <div style="font-size:10px;color:var(--c-text-secondary);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis"> overflow:hidden;display:-webkit-box;
-webkit-line-clamp:2;-webkit-box-orient:vertical;line-height:1.3">
${_esc(subtitle)} ${_esc(subtitle)}
</div> </div>
</div> </div>

View file

@ -13,6 +13,7 @@ window.Worlds = (() => {
let _lastUserId = undefined; let _lastUserId = undefined;
let _dogs = []; // gecachte Hundesliste let _dogs = []; // gecachte Hundesliste
let _dogIdx = 0; // aktuell angezeigter Hund let _dogIdx = 0; // aktuell angezeigter Hund
let _hasBgPhoto = false; // Hintergrund-Foto vorhanden?
// Touch-Tracking // Touch-Tracking
const _t = { x:0, y:0, active:false, vert:null, moved:0 }; const _t = { x:0, y:0, active:false, vert:null, moved:0 };
@ -49,6 +50,55 @@ window.Worlds = (() => {
_setupButtons(); _setupButtons();
_goTo(_cur, false); _goTo(_cur, false);
show(); 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) { function show(worldIdx) {
@ -187,7 +237,10 @@ window.Worlds = (() => {
function _setupButtons() { function _setupButtons() {
document.getElementById('worlds-fab')?.addEventListener('click', _openFab); 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) => { document.querySelectorAll('.wdot').forEach((dot, i) => {
dot.style.pointerEvents = 'auto'; dot.style.pointerEvents = 'auto';
dot.addEventListener('click', () => { dot.addEventListener('click', () => {
@ -414,7 +467,7 @@ window.Worlds = (() => {
{ icon:'wave-sine', color:'#06B6D4', label:'Gewicht messen', sub:'Aktuelles Gewicht eintragen', page:'health' }] }, { icon:'wave-sine', color:'#06B6D4', label:'Gewicht messen', sub:'Aktuelles Gewicht eintragen', page:'health' }] },
{ icon:'target', label:'Übungen', page:'uebungen', { icon:'target', label:'Übungen', page:'uebungen',
fab:[{ icon:'target', color:'#F59E0B', label:'Training aufzeichnen', sub:'Übung absolviert', 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' }] }, fab:[{ icon:'list-checks', color:'#10B981', label:'Plan erstellen', sub:'Neuen Trainingsplan anlegen', page:'trainingsplaene', action:'openNew' }] },
{ icon:'heart', label:'Adoption', page:'adoption', { icon:'heart', label:'Adoption', page:'adoption',
fab:[{ icon:'heart', color:'#EF4444', label:'Hund anbieten', sub:'Zur Adoption freigeben', page:'adoption', action:'openNew' }] }, 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:'sparkle', label:'Jobs', page:'jobs' },
{ icon:'book-open', label:'Knigge', page:'knigge' }, { icon:'book-open', label:'Knigge', page:'knigge' },
{ icon:'film-slate', label:'Filme', page:'movies' }, { 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' }] }, 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', { icon:'notebook', label:'Wurfverw.', page:'litters', role:'breeder',
fab:[{ icon:'notebook', color:'#10B981', label:'Wurf anlegen', sub:'Neuen Wurf eintragen', page:'litters', action:'openNew' }] }, 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' }] }, 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:'shield-check', label:'Moderation', page:'moderation', role:'mod' },
{ icon:'gear', label:'Admin', page:'admin', role:'admin' }, { 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 = { const _DEFAULT_CONFIG = {
jetzt: ['notes','expenses','erste-hilfe','playdate','chat','wetter','settings'], jetzt: ['notes','expenses','erste-hilfe','playdate','chat','wetter','social','moderation','admin'],
hund: ['diary','health','uebungen','trainingsplaene','adoption','sitting','wiki','wurfboerse'], hund: ['diary','health','uebungen','trainingsplaene','adoption','sitting','wiki','wurfboerse',
welt: ['map','forum','friends','walks','poison','recalls','lost','routes','events','jobs','knigge','movies'], '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 // _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"> user-select:none;-webkit-tap-highlight-color:transparent;touch-action:none">
${!c.pinned ? ` ${!c.pinned ? `
<button class="wc-remove" data-page="${c.page}" data-zone="${w}" <button class="wc-remove" data-page="${c.page}" data-zone="${w}"
style="position:absolute;top:-6px;right:-6px;width:18px;height:18px; style="position:absolute;top:-8px;right:-8px;width:24px;height:24px;
border-radius:50%;background:#EF4444;border:none;cursor:pointer; border-radius:50%;background:#EF4444;border:2px solid rgba(18,22,32,0.9);
display:flex;align-items:center;justify-content:center;z-index:2"> cursor:pointer;display:flex;align-items:center;justify-content:center;
<svg class="ph-icon" style="width:9px;height:9px;color:white"> 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> <use href="/icons/phosphor.svg#x"></use>
</svg> </svg>
</button>` : ` </button>` : `
@ -781,11 +842,19 @@ window.Worlds = (() => {
const track = document.getElementById('worlds-track'); const track = document.getElementById('worlds-track');
if (!track) return; if (!track) return;
if (url) { if (url) {
const img = new Image(); const toLoad = new Image();
img.onload = () => { track.style.backgroundImage = `url('${url}')`; track.style.backgroundSize = '100% auto'; track.style.backgroundPosition = '0 40%'; track.style.backgroundRepeat = 'no-repeat'; }; toLoad.onload = () => {
img.onerror = () => _applyBgImage(null); _hasBgPhoto = true;
img.src = url; 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 { } else {
_hasBgPhoto = false;
track.style.backgroundImage = 'linear-gradient(160deg,#1a1f35 0%,#16213e 33%,#1a2535 67%,#0f1921 100%)'; track.style.backgroundImage = 'linear-gradient(160deg,#1a1f35 0%,#16213e 33%,#1a2535 67%,#0f1921 100%)';
track.style.backgroundSize = '100% 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 greet = hour < 5 ? 'Gute Nacht' : hour < 12 ? 'Guten Morgen' : hour < 18 ? 'Hallo' : 'Guten Abend';
const firstName = user?.name?.split(' ')[0] || ''; const firstName = user?.name?.split(' ')[0] || '';
const dayStr = new Date().toLocaleDateString('de-DE', { weekday:'long', day:'numeric', month:'long' }); 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>` : ''; ? `<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 // Gassi-Score aus Wetterdaten berechnen
let streakVal = '—', streakCol = 'rgba(255,255,255,0.4)'; function _calcGassiScore(wd) {
if (user && dog) { if (!wd) return null;
try { let s = 10;
const sr = await _cachedGet(`streak_${dog.id}`, `/streak/${dog.id}`); const t = wd.temp_c ?? 20, p = wd.precip_prob ?? 0, wind = wd.wind_kmh ?? 0;
const s = sr.data; if (t > 30) s -= 3; else if (t > 25) s -= 1; else if (t < 0) s -= 3; else if (t < 5) s -= 1;
const streak = s?.current_streak || 0; if (p > 70) s -= 3; else if (p > 40) s -= 2; else if (p > 20) s -= 1;
const trainedToday = s?.last_training_date === new Date().toISOString().slice(0,10); if (wind > 60) s -= 2; else if (wind > 40) s -= 1;
streakCol = trainedToday ? '#10B981' : (streak > 0 ? '#F59E0B' : 'rgba(255,255,255,0.4)'); if (wd.thunderstorm) s -= 3;
streakVal = streak > 0 return Math.max(1, Math.min(10, s));
? (trainedToday ? `${streak} Tage` : `🔥 ${streak} Tage`)
: (trainedToday ? '✓ Heute' : 'Heute starten');
} catch {}
} }
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 // Alert-Reminder
const alertHtml = alertList.slice(0,1).map(a => ` const alertHtml = alertList.slice(0,1).map(a => `
@ -901,7 +973,7 @@ window.Worlds = (() => {
<div class="world-info-title"> <div class="world-info-title">
${_esc(greet)}${firstName ? `, <span style="color:var(--c-primary)">${_esc(firstName)}</span>` : ''}${stale} ${_esc(greet)}${firstName ? `, <span style="color:var(--c-primary)">${_esc(firstName)}</span>` : ''}${stale}
</div> </div>
<div class="world-info-sub">${_esc(dayStr)}${weatherLine ? ' · ' + weatherLine : ''}${totalKm != null ? ' · ' + totalKm + ' km' : ''}</div> <div class="world-info-sub">${_esc(dayStr)}</div>
</div> </div>
${user ? userAvatarHtml : ''} ${user ? userAvatarHtml : ''}
</div> </div>
@ -909,17 +981,25 @@ window.Worlds = (() => {
${alertHtml} ${alertHtml}
${user && dog ? ` ${user && dog ? `
<div class="wj-chip-row"> <div class="wj-chip-row">
<div class="wj-chip" data-wnav="uebungen"> <div class="wj-chip" data-wnav="wetter" style="${gassiScore ? `border-color:${gassiColor}44;background:${gassiColor}12;` : ''}">
<svg class="ph-icon" style="width:1.2rem;height:1.2rem;color:${streakCol}"> <div style="display:flex;align-items:center;gap:6px;width:100%">
<use href="/icons/phosphor.svg#target"></use></svg> <span style="font-size:1.4rem;line-height:1;flex-shrink:0">${weatherEmoji}</span>
<span class="wj-chip-label">Streak</span> <div style="flex:1;min-width:0">
<span class="wj-chip-val">${streakVal}</span> <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>
<div class="wj-chip" data-wnav="routes"> <div class="wj-chip" data-wnav="routes">
<svg class="ph-icon" style="width:1.2rem;height:1.2rem;color:var(--c-primary)"> <svg class="ph-icon" style="width:1.2rem;height:1.2rem;color:var(--c-primary)">
<use href="/icons/phosphor.svg#path"></use></svg> <use href="/icons/phosphor.svg#path"></use></svg>
<span class="wj-chip-label">Gassirunde</span> <span class="wj-chip-label">Gassirunde</span>
<span class="wj-chip-val" id="wj-route-val"></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>
<div class="wj-chip" data-wnav="uebungen"> <div class="wj-chip" data-wnav="uebungen">
<svg class="ph-icon" style="width:1.2rem;height:1.2rem;color:var(--c-primary)"> <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 || []; const dogs = dogsRes.data || [];
if (!dogs.length) { 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 = ` el.innerHTML = `
<div class="world-info-card" style="text-align:center"> <div class="world-top">
<div style="font-size:4rem;margin-bottom:12px">🐶</div> <div class="world-info-card" style="text-align:center">
<div class="world-info-title">Noch kein Hund angelegt</div> <div style="font-size:3.2rem;margin-bottom:10px">🐶</div>
<div class="world-info-sub" style="margin-bottom:20px">Erstelle das Profil deines Hundes</div> <div class="world-info-title">Dein Hund wartet!</div>
<button class="btn btn-primary" onclick="Worlds.navigateTo('dog-profile')">Hund anlegen</button> <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>`; </div>`;
el.querySelectorAll('[data-wnav]').forEach(e => e.addEventListener('click', () => navigateTo(e.dataset.wnav)));
return; return;
} }
@ -1093,6 +1201,25 @@ window.Worlds = (() => {
</div> </div>
</div> </div>
<div class="world-bottom"> <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-section-label">Alles über ${_esc(dog.name)}</div>
<div class="world-chips-grid"> <div class="world-chips-grid">
${chips.map(c => _chip(c.icon, c.label, c.page)).join('')} ${chips.map(c => _chip(c.icon, c.label, c.page)).join('')}

View file

@ -226,9 +226,90 @@
</div> </div>
</section> </section>
<!-- Foto-Galerie -->
<section>
<div class="section-label">Fotos — zur redaktionellen Verwendung freigegeben</div>
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:12px;margin-bottom:8px">
<!-- Herbst am Bach -->
<div style="border-radius:var(--radius);overflow:hidden;background:var(--white);border:1px solid var(--border)">
<div style="position:relative;aspect-ratio:4/3">
<img src="/img/banyaro/herbst_bach.webp" alt="Ban Yaro am Bach im Herbst"
style="width:100%;height:100%;object-fit:cover;display:block">
</div>
<div style="padding:10px 12px">
<div style="font-size:.8rem;font-weight:700;margin-bottom:2px">Herbst am Bach</div>
<div style="font-size:.7rem;color:var(--muted);margin-bottom:8px">8064 × 6048 px · 20 MB JPEG</div>
<a href="/img/banyaro/hires/banyaro_herbst_bach_hires.jpg"
download="banyaro-herbst-bach-hires.jpg"
style="display:inline-flex;align-items:center;gap:5px;background:var(--primary);color:white;
font-size:.72rem;font-weight:700;padding:5px 12px;border-radius:6px;text-decoration:none">
↓ Hi-Res herunterladen
</a>
</div>
</div>
<!-- Winter im Schnee -->
<div style="border-radius:var(--radius);overflow:hidden;background:var(--white);border:1px solid var(--border)">
<div style="position:relative;aspect-ratio:4/3">
<img src="/img/banyaro/winter_schnee.webp" alt="Ban Yaro im Schnee"
style="width:100%;height:100%;object-fit:cover;display:block">
</div>
<div style="padding:10px 12px">
<div style="font-size:.8rem;font-weight:700;margin-bottom:2px">Winter im Schnee</div>
<div style="font-size:.7rem;color:var(--muted);margin-bottom:8px">Original-Auflösung · JPEG</div>
<a href="/img/banyaro/hires/banyaro_winter_schnee_hires.jpg"
download="banyaro-winter-schnee-hires.jpg"
style="display:inline-flex;align-items:center;gap:5px;background:var(--primary);color:white;
font-size:.72rem;font-weight:700;padding:5px 12px;border-radius:6px;text-decoration:none">
↓ Hi-Res herunterladen
</a>
</div>
</div>
<!-- Frühling & Playdate -->
<div style="border-radius:var(--radius);overflow:hidden;background:var(--white);border:1px solid var(--border)">
<div style="position:relative;aspect-ratio:4/3">
<img src="/img/banyaro/fruehling_playdate.webp" alt="Ban Yaro spielt im Frühling"
style="width:100%;height:100%;object-fit:cover;display:block">
</div>
<div style="padding:10px 12px">
<div style="font-size:.8rem;font-weight:700;margin-bottom:2px">Frühling &amp; Playdate</div>
<div style="font-size:.7rem;color:var(--muted);margin-bottom:8px">3199 × 2648 px · 3,8 MB JPEG</div>
<a href="/img/banyaro/hires/banyaro_fruehling_playdate_hires.jpg"
download="banyaro-fruehling-playdate-hires.jpg"
style="display:inline-flex;align-items:center;gap:5px;background:var(--primary);color:white;
font-size:.72rem;font-weight:700;padding:5px 12px;border-radius:6px;text-decoration:none">
↓ Hi-Res herunterladen
</a>
</div>
</div>
<!-- Herbst & Neugier -->
<div style="border-radius:var(--radius);overflow:hidden;background:var(--white);border:1px solid var(--border)">
<div style="position:relative;aspect-ratio:4/3">
<img src="/img/banyaro/herbst_baum.webp" alt="Ban Yaro neugierig am Baum"
style="width:100%;height:100%;object-fit:cover;display:block">
</div>
<div style="padding:10px 12px">
<div style="font-size:.8rem;font-weight:700;margin-bottom:2px">Herbst &amp; Neugier</div>
<div style="font-size:.7rem;color:var(--muted);margin-bottom:8px">8064 × 6048 px · 17 MB JPEG</div>
<a href="/img/banyaro/hires/banyaro_herbst_baum_hires.jpg"
download="banyaro-herbst-baum-hires.jpg"
style="display:inline-flex;align-items:center;gap:5px;background:var(--primary);color:white;
font-size:.72rem;font-weight:700;padding:5px 12px;border-radius:6px;text-decoration:none">
↓ Hi-Res herunterladen
</a>
</div>
</div>
</div>
<p style="font-size:.78rem;color:var(--muted)">Alle Fotos: Ban Yaro (Kromfohrländer) · Fotograf: René Degelmann · Zur redaktionellen Verwendung freigegeben</p>
</section>
<!-- Screenshots --> <!-- Screenshots -->
<section> <section>
<div class="section-label">Screenshots — zur redaktionellen Verwendung freigegeben</div> <div class="section-label">App-Screenshots — zur redaktionellen Verwendung freigegeben</div>
<div class="download-grid"> <div class="download-grid">
<a class="download-card" href="/img/screenshots/screen-1.jpg" download="banyaro-tagebuch.jpg"> <a class="download-card" href="/img/screenshots/screen-1.jpg" download="banyaro-tagebuch.jpg">
<img class="thumb" src="/img/screenshots/screen-1.jpg" alt="Tagebuch"> <img class="thumb" src="/img/screenshots/screen-1.jpg" alt="Tagebuch">

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v700'; const CACHE_VERSION = 'by-v715';
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache