PHASE 1 — Sofort-Cleanup ohne Risiko: - Neue Datei utilities.css mit ~25 Klassen für häufige Kombinationen: * text-xs-muted, text-xs-secondary, text-sm-muted, text-sm-secondary * flex-gap-2/3, flex-col-gap-2/3/4, flex-center-gap-1/2/3 * flex-between, flex-1-min, mb-1/3, mt-1/3 * icon-xs/sm/md/lg, label-block, caption - index.html bindet utilities.css ein - mb-3/mt-3 ergänzt (waren in design-system.css unvollständig) PHASE 2 — .by-tab Modifier für Vereinheitlichung: - .by-tabs.grid (mit --tab-cols Variable für Admin/Health/etc.) - .by-tabs.sticky (Desktop vertikale Tabs für Admin) - .by-tabs.wrap (Zuchthunde, flex-wrap statt scroll) - .by-tabs.separated (Sitting, mit eigenem Hintergrund + Border) PHASE 3 — Inline-Style → Klassen-Migration (Python-Script): - 948 Inline-Styles entfernt (5101 → 4153, -18%) - 962 Migrationen über 47 Page-Dateien - Top-Treffer: admin.js (180), health.js (67), dog-profile.js (67), litters.js (62), settings.js (61), zuchthunde.js (51) - Patterns: text-muted, text-secondary, text-danger, text-xs-muted, text-sm-muted, grid-2 (Duplikat-Bug behoben!), flex-col-gap-3, p-3/4, mb-2/3/4, hidden, w-full, flex-1, ... - Bewahrt bestehende class-Attribute (mergt korrekt) Alle 19 Tests grün. Kein visueller Diff erwartet (gleiche Property-Werte).
1189 lines
49 KiB
JavaScript
1189 lines
49 KiB
JavaScript
/* ============================================================
|
||
BAN YARO — Wetter (7-Tage-Wettervorhersage)
|
||
Seiten-Modul: Hunde-optimierte Wettervorhersage mit GPS.
|
||
============================================================ */
|
||
|
||
window.Page_wetter = (() => {
|
||
|
||
// ----------------------------------------------------------
|
||
// KONSTANTEN
|
||
// ----------------------------------------------------------
|
||
// WMO-Code → Phosphor-Icon-Name (aus Sprite)
|
||
const WMO_ICON = {
|
||
0:'sun', 1:'sun-dim', 2:'cloud-sun', 3:'cloud',
|
||
45:'cloud-fog', 48:'cloud-fog',
|
||
51:'cloud-rain', 53:'cloud-rain', 55:'cloud-rain',
|
||
61:'cloud-rain', 63:'cloud-rain', 65:'cloud-rain',
|
||
71:'cloud-snow', 73:'cloud-snow', 75:'cloud-snow', 77:'snowflake',
|
||
80:'rainbow-cloud', 81:'cloud-rain', 82:'cloud-rain',
|
||
85:'cloud-snow', 86:'cloud-snow',
|
||
95:'cloud-lightning', 96:'cloud-lightning', 99:'cloud-lightning',
|
||
};
|
||
// Farben passend zum Wetter (für Icon-Tinting)
|
||
const WMO_COLOR = {
|
||
0:'#F59E0B', 1:'#F59E0B', 2:'#94A3B8', 3:'#64748B',
|
||
45:'#94A3B8', 48:'#94A3B8',
|
||
51:'#60A5FA', 53:'#3B82F6', 55:'#2563EB',
|
||
61:'#3B82F6', 63:'#2563EB', 65:'#1D4ED8',
|
||
71:'#BAE6FD', 73:'#7DD3FC', 75:'#38BDF8', 77:'#BAE6FD',
|
||
80:'#60A5FA', 81:'#3B82F6', 82:'#2563EB',
|
||
85:'#7DD3FC', 86:'#38BDF8',
|
||
95:'#7C3AED', 96:'#6D28D9', 99:'#5B21B6',
|
||
};
|
||
function _wmoIcon(code, size = '2rem', extraStyle = '') {
|
||
const name = WMO_ICON[code] || 'cloud';
|
||
const color = WMO_COLOR[code] || 'var(--c-text-secondary)';
|
||
return `<svg class="ph-icon" aria-hidden="true"
|
||
style="width:${size};height:${size};color:${color};flex-shrink:0;${extraStyle}">
|
||
<use href="/icons/phosphor.svg#${name}"></use>
|
||
</svg>`;
|
||
}
|
||
|
||
const WMO_DESC = {
|
||
0:'Klarer Himmel', 1:'Überwiegend klar', 2:'Teilweise bewölkt', 3:'Bedeckt',
|
||
45:'Nebel', 48:'Gefrierender Nebel',
|
||
51:'Leichter Sprühregen', 53:'Mäßiger Sprühregen', 55:'Starker Sprühregen',
|
||
61:'Leichter Regen', 63:'Mäßiger Regen', 65:'Starker Regen',
|
||
71:'Leichter Schneefall', 73:'Mäßiger Schneefall', 75:'Starker Schneefall', 77:'Schneekörner',
|
||
80:'Leichte Regenschauer', 81:'Mäßige Regenschauer', 82:'Starke Regenschauer',
|
||
85:'Leichte Schneeschauer', 86:'Starke Schneeschauer',
|
||
95:'Gewitter', 96:'Gewitter mit leichtem Hagel', 99:'Gewitter mit starkem Hagel'
|
||
};
|
||
|
||
const DAY_NAMES = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
||
|
||
// ----------------------------------------------------------
|
||
// MODUL-STATE
|
||
// ----------------------------------------------------------
|
||
let _container = null;
|
||
let _appState = null;
|
||
let _data = null;
|
||
let _selDay = 0;
|
||
let _loading = false;
|
||
let _recordsLoaded = false;
|
||
|
||
// ----------------------------------------------------------
|
||
// INIT
|
||
// ----------------------------------------------------------
|
||
async function init(container, appState) {
|
||
_container = container;
|
||
_appState = appState;
|
||
_selDay = 0;
|
||
_renderShell();
|
||
_tryAutoLocate();
|
||
}
|
||
|
||
async function refresh() {
|
||
_selDay = 0;
|
||
_recordsLoaded = false;
|
||
_renderShell();
|
||
_tryAutoLocate();
|
||
}
|
||
|
||
function _renderShell() {
|
||
_container.innerHTML = `
|
||
<div id="wttr-body">
|
||
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
|
||
<div class="mb-3">${_wmoIcon(2, '2.5rem')}</div>
|
||
<p class="text-secondary">Standort wird ermittelt…</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
const LS_LAST_POS = 'by_last_position';
|
||
|
||
async function _tryAutoLocate() {
|
||
// Letzte bekannte Position als Fallback einlesen (für offline / GPS-verweigert)
|
||
let cached = null;
|
||
try {
|
||
const raw = localStorage.getItem(LS_LAST_POS);
|
||
if (raw) cached = JSON.parse(raw);
|
||
} catch {}
|
||
|
||
try {
|
||
const pos = await API.getLocation({ timeout: 8000, maximumAge: 300_000 });
|
||
try {
|
||
localStorage.setItem(LS_LAST_POS, JSON.stringify({
|
||
lat: pos.lat, lon: pos.lon, ts: Date.now(),
|
||
}));
|
||
} catch {}
|
||
await _loadData(pos.lat, pos.lon);
|
||
} catch (err) {
|
||
// GPS fehlgeschlagen → letzte bekannte Position nutzen (statt Error-Banner)
|
||
if (cached?.lat != null && cached?.lon != null) {
|
||
await _loadData(cached.lat, cached.lon);
|
||
} else {
|
||
_showLocationError(err?.code);
|
||
}
|
||
}
|
||
}
|
||
|
||
function _showLocationError(errCode) {
|
||
const body = _container?.querySelector('#wttr-body');
|
||
if (!body) return;
|
||
const isLoggedIn = !!_appState?.user;
|
||
const isDenied = errCode === 1; // GeolocationPositionError.PERMISSION_DENIED
|
||
const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent);
|
||
|
||
const deniedHint = isDenied ? `
|
||
<div style="background:rgba(245,158,11,0.08);border:1px solid rgba(245,158,11,0.4);
|
||
border-radius:var(--radius-lg);padding:var(--space-4);margin-bottom:var(--space-4)">
|
||
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
|
||
<svg class="ph-icon" style="width:1.2rem;height:1.2rem;color:#F59E0B;flex-shrink:0">
|
||
<use href="/icons/phosphor.svg#warning"></use>
|
||
</svg>
|
||
<div style="font-weight:700;font-size:var(--text-sm)">Standort-Zugriff blockiert</div>
|
||
</div>
|
||
${isIos ? `
|
||
<div style="color:var(--c-text-secondary);font-size:var(--text-sm);margin-bottom:var(--space-3)">
|
||
<b>Wichtig:</b> Die App läuft getrennt von Safari — Safari-Einstellungen gelten hier nicht.
|
||
</div>
|
||
<ol style="margin:0;padding-left:var(--space-5);display:flex;flex-direction:column;gap:var(--space-1);
|
||
color:var(--c-text-secondary);font-size:var(--text-sm)">
|
||
<li>Öffne <b>Einstellungen → Datenschutz & Sicherheit → Ortungsdienste</b></li>
|
||
<li>Scrolle ganz nach unten zu <b>Ban Yaro</b> (nicht Safari!)</li>
|
||
<li>Wähle <b>„Beim Verwenden der App"</b></li>
|
||
<li>Komm zurück und tippe nochmal auf den Button</li>
|
||
</ol>
|
||
<div style="margin-top:var(--space-3);font-size:var(--text-xs);color:var(--c-text-secondary)">
|
||
<b>Letzter Ausweg:</b> Einstellungen → Apps → Safari → Erweitert → Website-Daten → banyaro.app → löschen. Danach nochmal öffnen und Button tippen.
|
||
</div>` : `
|
||
<div style="color:var(--c-text-secondary);font-size:var(--text-sm)">
|
||
Klicke auf das Schloss-Symbol in der Adressleiste → <b>Standort</b> → <b>Erlauben</b>, dann nochmal tippen.
|
||
</div>`}
|
||
</div>` : '';
|
||
|
||
body.innerHTML = `
|
||
<div style="max-width:420px;margin:0 auto;padding:var(--space-6) var(--space-4)">
|
||
|
||
${deniedHint}
|
||
|
||
<!-- 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 1–10', '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 class="flex-col-gap-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 & 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 & 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');
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// DATEN LADEN
|
||
// ----------------------------------------------------------
|
||
async function _loadData(lat, lon) {
|
||
if (_loading) return;
|
||
_loading = true;
|
||
try {
|
||
_data = await API.weather.forecast(lat, lon);
|
||
_selDay = 0;
|
||
_renderWeather();
|
||
} catch {
|
||
const body = _container.querySelector('#wttr-body');
|
||
if (body) 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 class="mb-2">Wetter nicht verfügbar</h3>
|
||
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-5)">
|
||
Die Wetterdaten konnten nicht geladen werden.
|
||
</p>
|
||
<button class="btn btn-primary" id="wttr-btn-reload">
|
||
${UI.icon('arrow-clockwise')} Erneut laden
|
||
</button>
|
||
</div>
|
||
`;
|
||
body?.querySelector('#wttr-btn-reload')?.addEventListener('click', () => {
|
||
refresh();
|
||
});
|
||
} finally {
|
||
_loading = false;
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// HAUPT-RENDER
|
||
// ----------------------------------------------------------
|
||
function _renderWeather() {
|
||
const body = _container.querySelector('#wttr-body');
|
||
if (!body || !_data) return;
|
||
|
||
const days = _data.days || [];
|
||
if (!days.length) return;
|
||
|
||
body.innerHTML = `
|
||
<!-- 7-Tage-Strip -->
|
||
<div id="wttr-strip-wrap"
|
||
style="overflow-x:auto;-webkit-overflow-scrolling:touch;
|
||
margin-bottom:var(--space-4);
|
||
scrollbar-width:none">
|
||
<div id="wttr-strip"
|
||
style="display:flex;gap:var(--space-2);padding-bottom:4px;min-width:max-content">
|
||
${days.map((d, i) => _dayCard(d, i)).join('')}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Detail-Card -->
|
||
<div id="wttr-detail" class="section-card mb-4">
|
||
</div>
|
||
|
||
<!-- Niederschlagswahrscheinlichkeit Zeitskala -->
|
||
<div id="wttr-rain" class="section-card mb-4">
|
||
</div>
|
||
|
||
<!-- Hunde-Wetter -->
|
||
<div id="wttr-dog" class="section-card">
|
||
</div>
|
||
|
||
<!-- Meine Wetterrekorde -->
|
||
<div id="wttr-records">
|
||
</div>
|
||
`;
|
||
|
||
// Strip-Klick-Events
|
||
body.querySelectorAll('[data-wttr-day]').forEach(card => {
|
||
card.addEventListener('click', () => {
|
||
_selDay = parseInt(card.dataset.wttrDay);
|
||
_updateStrip();
|
||
_renderDetail();
|
||
_renderRainTimeline();
|
||
_renderDog();
|
||
});
|
||
});
|
||
|
||
_renderDetail();
|
||
_renderRainTimeline();
|
||
_renderDog();
|
||
_loadRecords();
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// STRIP AKTUALISIEREN (aktiver Tag)
|
||
// ----------------------------------------------------------
|
||
function _updateStrip() {
|
||
const body = _container.querySelector('#wttr-body');
|
||
if (!body) return;
|
||
const days = _data?.days || [];
|
||
body.querySelectorAll('[data-wttr-day]').forEach((card, i) => {
|
||
const active = i === _selDay;
|
||
card.style.background = active ? 'var(--c-primary)' : 'var(--c-bg-card)';
|
||
card.style.color = active ? '#fff' : 'var(--c-text)';
|
||
card.style.borderColor = active ? 'var(--c-primary)' : 'var(--c-border)';
|
||
card.style.transform = active ? 'translateY(-2px)' : '';
|
||
card.style.boxShadow = active ? '0 4px 12px rgba(196,132,58,0.3)' : '0 1px 3px rgba(0,0,0,0.07)';
|
||
// Temperatur-Farbe im aktiven Zustand
|
||
const tempEl = card.querySelector('.wttr-temp');
|
||
if (tempEl) tempEl.style.color = active ? 'rgba(255,255,255,0.85)' : 'var(--c-text-secondary)';
|
||
const precipEl = card.querySelector('.wttr-precip');
|
||
if (precipEl) precipEl.style.color = active ? 'rgba(255,255,255,0.75)' : 'var(--c-text-secondary)';
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// TAG-KARTE (Strip)
|
||
// ----------------------------------------------------------
|
||
function _dayCard(d, i) {
|
||
const active = i === _selDay;
|
||
const dateObj = new Date(d.date);
|
||
const dayName = i === 0 ? 'Heute' : DAY_NAMES[dateObj.getDay()];
|
||
const bg = active ? 'var(--c-primary)' : 'var(--c-bg-card)';
|
||
const col = active ? '#fff' : 'var(--c-text)';
|
||
const shadow = active
|
||
? '0 4px 12px rgba(196,132,58,0.3)'
|
||
: '0 1px 3px rgba(0,0,0,0.07)';
|
||
const border = active ? 'var(--c-primary)' : 'var(--c-border)';
|
||
const transform = active ? 'translateY(-2px)' : '';
|
||
const textSec = active ? 'rgba(255,255,255,0.85)' : 'var(--c-text-secondary)';
|
||
const textMut = active ? 'rgba(255,255,255,0.75)' : 'var(--c-text-secondary)';
|
||
|
||
return `
|
||
<div data-wttr-day="${i}"
|
||
style="display:flex;flex-direction:column;align-items:center;
|
||
min-width:72px;padding:var(--space-3) var(--space-2);
|
||
border-radius:var(--radius);border:1.5px solid ${border};
|
||
background:${bg};color:${col};cursor:pointer;
|
||
box-shadow:${shadow};transform:${transform};
|
||
transition:all .15s;user-select:none">
|
||
<span style="font-size:var(--text-xs);font-weight:600;
|
||
margin-bottom:var(--space-1)">${_esc(dayName)}</span>
|
||
<div style="margin-bottom:var(--space-1)">${_wmoIcon(d.weathercode, '1.5rem', active ? 'filter:brightness(0) invert(1)' : '')}</div>
|
||
<span class="wttr-temp"
|
||
style="font-size:var(--text-xs);color:${textSec};white-space:nowrap">
|
||
${Math.round(d.temp_max)}°/<span style="opacity:.75">${Math.round(d.temp_min)}°</span>
|
||
</span>
|
||
<span class="wttr-precip"
|
||
style="font-size:10px;color:${textMut};margin-top:2px">
|
||
<svg class="ph-icon" style="width:10px;height:10px;vertical-align:-1px;color:#60A5FA"><use href="/icons/phosphor.svg#drop"></use></svg>${d.precip_prob ?? 0}%
|
||
</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// DETAIL-CARD
|
||
// ----------------------------------------------------------
|
||
function _renderDetail() {
|
||
const el = _container.querySelector('#wttr-detail');
|
||
if (!el || !_data) return;
|
||
const d = (_data.days || [])[_selDay];
|
||
if (!d) return;
|
||
|
||
const desc = WMO_DESC[d.weathercode] || '';
|
||
const [uvLabel, uvColor] = _uvLabel(d.uv_index ?? 0);
|
||
const uvPct = Math.min(100, ((d.uv_index ?? 0) / 11) * 100);
|
||
const bft = _beaufort(d.wind_kmh ?? 0);
|
||
const windDir = d.wind_dir_deg ?? 0;
|
||
const compass = d.wind_dir ?? _compass(windDir);
|
||
|
||
// Sunrise/Sunset Balken
|
||
const now = new Date();
|
||
const sunriseStr = d.sunrise || '';
|
||
const sunsetStr = d.sunset || '';
|
||
let sunPct = 0;
|
||
if (sunriseStr && sunsetStr) {
|
||
const [rH, rM] = sunriseStr.split(':').map(Number);
|
||
const [sH, sM] = sunsetStr.split(':').map(Number);
|
||
const riseMin = rH * 60 + rM;
|
||
const setMin = sH * 60 + sM;
|
||
const curMin = now.getHours() * 60 + now.getMinutes();
|
||
sunPct = _selDay === 0
|
||
? Math.min(100, Math.max(0, ((curMin - riseMin) / (setMin - riseMin)) * 100))
|
||
: 0;
|
||
}
|
||
|
||
const locName = _data.location_name ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">${_esc(_data.location_name)}</div>` : '';
|
||
el.innerHTML = `
|
||
${locName}
|
||
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4)">
|
||
${_wmoIcon(d.weathercode, '3.5rem')}
|
||
<div>
|
||
<div style="font-weight:700;font-size:var(--text-lg)">${_esc(desc)}</div>
|
||
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-primary);line-height:1.1">
|
||
${Math.round(d.temp_max)}°
|
||
<span style="font-size:var(--text-base);font-weight:400;color:var(--c-text-secondary)">
|
||
/ ${Math.round(d.temp_min)}°
|
||
</span>
|
||
</div>
|
||
${d.feels_max != null ? `
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
|
||
Gefühlt ${Math.round(d.feels_max)}° / ${Math.round(d.feels_min ?? d.feels_max)}°
|
||
</div>` : ''}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Gassi-Score -->
|
||
${_gassiScoreBadge(d)}
|
||
|
||
<!-- Sonnenaufgang / -untergang -->
|
||
${sunriseStr && sunsetStr ? `
|
||
<div class="mb-4">
|
||
<div style="display:flex;justify-content:space-between;
|
||
font-size:var(--text-xs);color:var(--c-text-secondary);
|
||
margin-bottom:var(--space-1)">
|
||
<span style="display:flex;align-items:center;gap:4px">
|
||
<svg class="ph-icon" style="width:14px;height:14px;color:#F97316"><use href="/icons/phosphor.svg#sun-horizon"></use></svg>
|
||
${_esc(sunriseStr)}
|
||
</span>
|
||
<span style="display:flex;align-items:center;gap:4px">
|
||
${_esc(sunsetStr)}
|
||
<svg class="ph-icon" style="width:14px;height:14px;color:#7C3AED"><use href="/icons/phosphor.svg#moon-stars"></use></svg>
|
||
</span>
|
||
</div>
|
||
<div style="height:6px;border-radius:999px;background:var(--c-border);overflow:hidden">
|
||
<div style="height:100%;width:${sunPct}%;
|
||
background:linear-gradient(90deg,#f97316,#facc15);
|
||
border-radius:999px;transition:width .4s"></div>
|
||
</div>
|
||
</div>` : ''}
|
||
|
||
<!-- Wind -->
|
||
<div style="display:flex;align-items:center;gap:var(--space-3);
|
||
padding:var(--space-3);border-radius:var(--radius);
|
||
background:var(--c-bg-card);border:1px solid var(--c-border);
|
||
margin-bottom:var(--space-3)">
|
||
<span style="font-size:1.4rem;transform:rotate(${windDir}deg);display:inline-block;line-height:1">
|
||
${UI.icon('arrow-up')}
|
||
</span>
|
||
<div class="flex-1">
|
||
<div style="font-size:var(--text-sm);font-weight:600">
|
||
${_esc(compass)} · ${Math.round(d.wind_kmh ?? 0)} km/h
|
||
</div>
|
||
<div class="text-xs-secondary">${_esc(bft)}</div>
|
||
</div>
|
||
${d.precip_sum != null ? `
|
||
<div class="text-right">
|
||
<div style="font-size:var(--text-sm);font-weight:600">
|
||
${d.precip_sum} mm
|
||
</div>
|
||
<div class="text-xs-secondary">Niederschlag</div>
|
||
</div>` : ''}
|
||
</div>
|
||
|
||
<!-- UV-Index -->
|
||
<div>
|
||
<div style="display:flex;justify-content:space-between;
|
||
font-size:var(--text-xs);margin-bottom:4px">
|
||
<span class="text-secondary">UV-Index</span>
|
||
<span style="font-weight:600;color:${uvColor}">
|
||
${d.uv_index ?? 0} — ${_esc(uvLabel)}
|
||
</span>
|
||
</div>
|
||
<div style="height:6px;border-radius:999px;background:var(--c-border);overflow:hidden">
|
||
<div style="height:100%;width:${uvPct}%;background:${uvColor};
|
||
border-radius:999px;transition:width .4s"></div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// NIEDERSCHLAGS-ZEITSKALA (stündlich)
|
||
// ----------------------------------------------------------
|
||
function _renderRainTimeline() {
|
||
const el = _container.querySelector('#wttr-rain');
|
||
if (!el || !_data) return;
|
||
const d = (_data.days || [])[_selDay];
|
||
if (!d) return;
|
||
|
||
const hourly = d.hourly || [];
|
||
// Filtere auf Stunden mit Daten, die eine Niederschlagswahrscheinlichkeit haben
|
||
const entries = hourly.filter(h => h.precip_prob != null);
|
||
if (!entries.length) { el.style.display = 'none'; return; }
|
||
el.style.display = '';
|
||
|
||
// Für "Heute" (Tag 0): ab jetzt, sonst alle 24h
|
||
const now = new Date();
|
||
const nowMin = now.getHours() * 60 + now.getMinutes();
|
||
let slots = entries;
|
||
if (_selDay === 0) {
|
||
// Zeige ab der aktuellen Stunde (und die letzten 2h als Kontext)
|
||
const pastCutoff = now.getHours() - 2;
|
||
slots = entries.filter(h => {
|
||
const hHour = parseInt(h.hour.split(':')[0]);
|
||
return hHour >= pastCutoff;
|
||
});
|
||
// Falls nichts übrig bleibt, zeige alles
|
||
if (!slots.length) slots = entries;
|
||
}
|
||
|
||
// Max probability für Skalierung (mindestens 30 damit die Balken sichtbar sind)
|
||
const maxProb = Math.max(30, ...slots.map(h => h.precip_prob ?? 0));
|
||
|
||
// Farb-Funktion: blau basierend auf Wahrscheinlichkeit
|
||
function _rainColor(prob) {
|
||
if (prob < 10) return 'rgba(148,163,184,0.4)'; // grau, kaum Regen
|
||
if (prob < 25) return 'rgba(147,197,253,0.65)'; // hellblau
|
||
if (prob < 50) return 'rgba(96,165,250,0.8)'; // blau
|
||
if (prob < 75) return 'rgba(59,130,246,0.9)'; // kräftig blau
|
||
return 'rgba(29,78,216,1)'; // dunkelblau
|
||
}
|
||
|
||
// Aktuell aktiver Slot (nur bei Heute)
|
||
const currentHour = now.getHours();
|
||
|
||
const bars = slots.map(h => {
|
||
const prob = h.precip_prob ?? 0;
|
||
const hHour = parseInt(h.hour.split(':')[0]);
|
||
const isNow = _selDay === 0 && hHour === currentHour;
|
||
const barH = Math.max(2, Math.round((prob / 100) * 56)); // max 56px Balkenhöhe
|
||
const color = _rainColor(prob);
|
||
const labelHour = h.hour.substring(0, 2); // 'HH'
|
||
|
||
return `
|
||
<div style="display:flex;flex-direction:column;align-items:center;
|
||
gap:2px;min-width:38px;flex-shrink:0;position:relative">
|
||
<!-- Prozentzahl oben (nur bei ≥20%) -->
|
||
<div style="font-size:9px;color:var(--c-text-secondary);
|
||
height:13px;line-height:13px;font-weight:600">
|
||
${prob >= 20 ? prob + '%' : ''}
|
||
</div>
|
||
<!-- Balken-Container mit fixer Höhe -->
|
||
<div style="height:56px;display:flex;align-items:flex-end;width:100%">
|
||
<div style="
|
||
width:100%;
|
||
height:${barH}px;
|
||
background:${color};
|
||
border-radius:3px 3px 0 0;
|
||
transition:height .3s;
|
||
${isNow ? 'box-shadow:0 0 0 1.5px var(--c-primary);border-radius:3px 3px 0 0;' : ''}
|
||
"></div>
|
||
</div>
|
||
<!-- Stunden-Label unten -->
|
||
<div style="font-size:9px;font-weight:${isNow ? '700' : '400'};
|
||
color:${isNow ? 'var(--c-primary)' : 'var(--c-text-secondary)'};
|
||
line-height:1">
|
||
${isNow ? 'jetzt' : labelHour + 'h'}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
// Gibt es überhaupt nennenswerten Niederschlag?
|
||
const hasRain = slots.some(h => (h.precip_prob ?? 0) >= 10);
|
||
const titleColor = hasRain ? '#60A5FA' : 'var(--c-text-secondary)';
|
||
const titleIcon = hasRain ? 'cloud-rain' : 'cloud';
|
||
|
||
el.innerHTML = `
|
||
<div style="display:flex;align-items:center;gap:var(--space-2);
|
||
margin-bottom:var(--space-3)">
|
||
<svg class="ph-icon" style="width:1.1rem;height:1.1rem;color:${titleColor}">
|
||
<use href="/icons/phosphor.svg#${titleIcon}"></use>
|
||
</svg>
|
||
<span style="font-size:var(--text-sm);font-weight:700">
|
||
Niederschlagswahrscheinlichkeit
|
||
</span>
|
||
<span style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-left:auto">
|
||
${_selDay === 0 ? 'heute' : _esc(d.date ? new Date(d.date + 'T12:00').toLocaleDateString('de', {weekday:'short', day:'numeric', month:'short'}) : '')}
|
||
</span>
|
||
</div>
|
||
<!-- Baseline -->
|
||
<div style="position:relative">
|
||
<div style="overflow-x:auto;-webkit-overflow-scrolling:touch;
|
||
scrollbar-width:none;padding-bottom:2px">
|
||
<div style="display:flex;gap:3px;min-width:max-content;padding:0 2px">
|
||
${bars}
|
||
</div>
|
||
</div>
|
||
<!-- 0%-Linie -->
|
||
<div style="height:1px;background:var(--c-border);margin-top:2px"></div>
|
||
</div>
|
||
${!hasRain ? `
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
||
margin-top:var(--space-2);text-align:center">
|
||
Kein Regen erwartet
|
||
</div>` : ''}
|
||
`;
|
||
|
||
// Scroll zum aktuellen Slot wenn Heute
|
||
if (_selDay === 0) {
|
||
requestAnimationFrame(() => {
|
||
const wrap = el.querySelector('div[style*="overflow-x"]');
|
||
if (!wrap) return;
|
||
const nowIdx = slots.findIndex(h => parseInt(h.hour.split(':')[0]) === currentHour);
|
||
if (nowIdx > 2) {
|
||
wrap.scrollLeft = (nowIdx - 2) * 41; // ca. 38px + 3px gap
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// HUNDE-WETTER
|
||
// ----------------------------------------------------------
|
||
function _renderDog() {
|
||
const el = _container.querySelector('#wttr-dog');
|
||
if (!el || !_data) return;
|
||
const d = (_data.days || [])[_selDay];
|
||
if (!d) return;
|
||
|
||
const _POLLEN_NAMES = { erle:'Erle', birke:'Birke', graeser:'Gräser', beifuss:'Beifuß', ambrosia:'Ambrosia' };
|
||
const felltyp = (_appState?.activeDog ?? _appState?.dogs?.[0])?.fell_typ || null;
|
||
const _wl = _dogWeatherLabel(d, felltyp);
|
||
let html = `
|
||
<div style="border-radius:var(--radius);padding:var(--space-4);
|
||
background:${_wl.color}18;border:1px solid ${_wl.color}44;
|
||
margin-bottom:var(--space-4);text-align:center">
|
||
<div style="font-size:2rem;line-height:1;margin-bottom:4px">${_wl.emoji}</div>
|
||
<div style="font-weight:800;font-size:var(--text-lg);color:${_wl.color};line-height:1.2">
|
||
${_esc(_wl.label)}
|
||
</div>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:4px">
|
||
${_esc(_wl.sub)}
|
||
</div>
|
||
</div>
|
||
<h3 style="font-size:var(--text-base);font-weight:700;margin-bottom:var(--space-4)">
|
||
<svg class="ph-icon" style="width:1.1em;height:1.1em;vertical-align:-2px;color:var(--c-primary)"><use href="/icons/phosphor.svg#paw-print"></use></svg>
|
||
Hunde-Hinweise
|
||
</h3>`;
|
||
|
||
// Asphalt-Temperatur
|
||
if (d.asphalt_temp != null) {
|
||
const [aspText, aspColor, aspAdvice] = _asphaltLevel(d.asphalt_temp);
|
||
html += `
|
||
<div style="display:flex;align-items:flex-start;gap:var(--space-3);
|
||
padding:var(--space-3);border-radius:var(--radius);
|
||
background:${aspColor}1a;border:1px solid ${aspColor}55;
|
||
margin-bottom:var(--space-3)">
|
||
<svg class="ph-icon" style="width:1.3rem;height:1.3rem;flex-shrink:0;color:var(--c-primary)"><use href="/icons/phosphor.svg#paw-print"></use></svg>
|
||
<div class="flex-1">
|
||
<div style="font-weight:600;font-size:var(--text-sm);color:${aspColor}">
|
||
Asphalt ~${Math.round(d.asphalt_temp)}°C — ${_esc(aspText)}
|
||
</div>
|
||
${aspAdvice ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
|
||
${_esc(aspAdvice)}
|
||
</div>` : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Pfoten-Kälteschutz
|
||
if (d.paw_cold) {
|
||
html += `
|
||
<div style="display:flex;align-items:center;gap:var(--space-3);
|
||
padding:var(--space-3);border-radius:var(--radius);
|
||
background:#3b82f61a;border:1px solid #3b82f655;
|
||
margin-bottom:var(--space-3)">
|
||
<svg class="ph-icon" style="width:1.3rem;height:1.3rem;color:#38BDF8"><use href="/icons/phosphor.svg#snowflake"></use></svg>
|
||
<div class="text-sm">
|
||
<strong>Kälteschutz für Pfoten:</strong>
|
||
Eis und Streusalz können die Pfoten reizen. Pfotenpflege empfohlen.
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Gewitter
|
||
if (d.thunderstorm) {
|
||
html += `
|
||
<div style="display:flex;align-items:center;gap:var(--space-3);
|
||
padding:var(--space-3);border-radius:var(--radius);
|
||
background:#f59e0b1a;border:1px solid #f59e0b55;
|
||
margin-bottom:var(--space-3)">
|
||
<svg class="ph-icon" style="width:1.3rem;height:1.3rem;color:#7C3AED"><use href="/icons/phosphor.svg#cloud-lightning"></use></svg>
|
||
<div class="text-sm">
|
||
<strong>Gewitter erwartet:</strong>
|
||
Hunde können auf Gewitter sensibel reagieren. Sichere Umgebung schaffen.
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Pollenflug
|
||
const pollen = d.pollen;
|
||
if (pollen && typeof pollen === 'object' && Object.keys(pollen).length) {
|
||
const pollenEntries = Object.entries(pollen)
|
||
.filter(([, v]) => v != null && v.level > 0);
|
||
if (pollenEntries.length) {
|
||
html += `
|
||
<div class="mb-3">
|
||
<div style="font-size:var(--text-xs);font-weight:600;
|
||
color:var(--c-text-secondary);margin-bottom:var(--space-2)">
|
||
<svg class="ph-icon" style="width:1em;height:1em;vertical-align:-1px;color:#16A34A"><use href="/icons/phosphor.svg#leaf"></use></svg>
|
||
Pollenflug
|
||
</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)">
|
||
${pollenEntries.map(([key, lvlObj]) => {
|
||
const col = _pollenColor(lvlObj?.level ?? 0);
|
||
const name = _POLLEN_NAMES[key] || key;
|
||
const lbl = lvlObj?.label || '';
|
||
return `<span style="display:inline-flex;align-items:center;gap:4px;
|
||
font-size:var(--text-xs);border-radius:999px;
|
||
padding:3px 10px;background:${col}22;
|
||
border:1px solid ${col}55;color:${col};font-weight:600">
|
||
<span style="width:6px;height:6px;border-radius:50%;background:${col};display:inline-block"></span>
|
||
${_esc(name)}: ${_esc(lbl)}
|
||
</span>`;
|
||
}).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
// Zecken
|
||
if (d.zecken != null) {
|
||
const [tickLabel, tickColor] = _tickLevel(d.zecken);
|
||
html += `
|
||
<div style="display:flex;align-items:center;gap:var(--space-3);
|
||
padding:var(--space-3);border-radius:var(--radius);
|
||
background:${tickColor}1a;border:1px solid ${tickColor}55;
|
||
margin-bottom:var(--space-3)">
|
||
<svg class="ph-icon" style="width:1.3rem;height:1.3rem;color:#92400E"><use href="/icons/phosphor.svg#bug"></use></svg>
|
||
<div class="flex-1">
|
||
<span style="font-size:var(--text-sm);font-weight:600">Zecken-Risiko: </span>
|
||
<span style="font-size:var(--text-sm);color:${tickColor};font-weight:700">
|
||
${_esc(tickLabel)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Fell-spezifische Hinweise
|
||
if (felltyp) {
|
||
const tempNow = d.temp_max ?? 20;
|
||
let fellHint = null;
|
||
if (felltyp === 'doppel' && tempNow > 20) {
|
||
fellHint = { icon: 'thermometer-hot', color: '#F97316',
|
||
text: 'Doppeltes Fell — heute besonders auf Überhitzung achten.' };
|
||
} else if (felltyp === 'nackt' && tempNow < 15) {
|
||
fellHint = { icon: 'coat-hanger', color: '#60A5FA',
|
||
text: 'Nackthund braucht heute eine Hundejacke oder einen -pullover.' };
|
||
} else if (felltyp === 'kurz' && tempNow < 5) {
|
||
fellHint = { icon: 'snowflake', color: '#38BDF8',
|
||
text: 'Kurzhaar friert schnell — Hundemantel empfohlen.' };
|
||
}
|
||
if (fellHint) {
|
||
html += `
|
||
<div style="display:flex;align-items:center;gap:var(--space-3);
|
||
padding:var(--space-3);border-radius:var(--radius);
|
||
background:${fellHint.color}1a;border:1px solid ${fellHint.color}55;
|
||
margin-bottom:var(--space-3)">
|
||
<svg class="ph-icon" style="width:1.3rem;height:1.3rem;flex-shrink:0;color:${fellHint.color}">
|
||
<use href="/icons/phosphor.svg#${fellHint.icon}"></use>
|
||
</svg>
|
||
<div class="text-sm">${_esc(fellHint.text)}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
// Schnüffel-Index + Hunde-Alter Chips
|
||
const ageYears = _dogAgeYears();
|
||
html += _dogAgeChip(ageYears);
|
||
|
||
html += `
|
||
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-bottom:var(--space-3)">
|
||
${_schnueffelChip(d)}
|
||
</div>
|
||
`;
|
||
|
||
// Wenn keine Hunde-Daten vorhanden
|
||
if (!d.asphalt_temp && !d.paw_cold && !d.thunderstorm
|
||
&& !d.zecken && !(pollen && Object.keys(pollen).length)) {
|
||
html += `
|
||
<p class="text-sm-secondary">
|
||
Keine besonderen Hinweise für heute.
|
||
</p>
|
||
`;
|
||
}
|
||
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// GASSI-SCORE (1–10)
|
||
// ----------------------------------------------------------
|
||
function _gassiScore(d) {
|
||
let score = 10;
|
||
const temp = d.temp_max ?? 20;
|
||
const precip = d.precip_prob ?? 0;
|
||
const wind = d.wind_kmh ?? 0;
|
||
const asphalt = d.asphalt_temp ?? 0;
|
||
|
||
// Temperatur (ideal: 10–20°C)
|
||
if (temp > 30) score -= 3;
|
||
else if (temp > 25) score -= 1;
|
||
else if (temp < 0) score -= 3;
|
||
else if (temp < 5) score -= 1;
|
||
|
||
// Regen
|
||
if (precip > 70) score -= 3;
|
||
else if (precip > 40) score -= 2;
|
||
else if (precip > 20) score -= 1;
|
||
|
||
// Wind
|
||
if (wind > 60) score -= 2;
|
||
else if (wind > 40) score -= 1;
|
||
|
||
// Asphalt
|
||
if (asphalt > 55) score -= 2;
|
||
else if (asphalt > 45) score -= 1;
|
||
|
||
// Gewitter
|
||
if (d.thunderstorm) score -= 3;
|
||
|
||
return Math.max(1, Math.min(10, score));
|
||
}
|
||
|
||
function _gassiScoreBadge(d) {
|
||
const score = _gassiScore(d);
|
||
let color, text;
|
||
if (score >= 8) {
|
||
color = '#10B981';
|
||
text = 'Toller Gassi-Tag!';
|
||
} else if (score >= 5) {
|
||
color = '#F59E0B';
|
||
text = 'Geht so';
|
||
} else {
|
||
color = '#EF4444';
|
||
text = 'Lieber drinbleiben';
|
||
}
|
||
return `
|
||
<div style="display:flex;align-items:center;justify-content:center;
|
||
gap:var(--space-3);margin-bottom:var(--space-4);
|
||
padding:var(--space-3) var(--space-4);
|
||
border-radius:999px;
|
||
background:${color}1a;border:1.5px solid ${color}55">
|
||
<span style="font-size:var(--text-xs);font-weight:700;
|
||
color:var(--c-text-secondary);white-space:nowrap">🐾 Gassi-Score</span>
|
||
<span style="font-size:var(--text-2xl);font-weight:900;color:${color};line-height:1">
|
||
${score}
|
||
</span>
|
||
<span class="text-xs-secondary">/ 10</span>
|
||
<span style="font-size:var(--text-xs);font-weight:600;color:${color};
|
||
white-space:nowrap">— ${_esc(text)}</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// SCHNÜFFEL-INDEX
|
||
// ----------------------------------------------------------
|
||
function _schnueffelIndex(d) {
|
||
const temp = d.temp_max ?? 20;
|
||
const precip = d.precip_prob ?? 0;
|
||
|
||
// Feuchtigkeit aus precip_prob ableiten
|
||
const feucht = precip > 60 ? 'feucht' : precip > 30 ? 'leicht-feucht' : 'trocken';
|
||
|
||
if (feucht === 'feucht' && temp >= 10 && temp <= 18)
|
||
return { label:'Exzellent 👃', color:'#10B981' };
|
||
if (feucht === 'feucht' && temp > 10 && temp <= 22)
|
||
return { label:'Sehr gut 👃', color:'#34D399' };
|
||
if (temp < 5)
|
||
return { label:'Gut (kalte Luft trägt Gerüche)', color:'#60A5FA' };
|
||
if (feucht === 'leicht-feucht' && temp >= 10 && temp <= 22)
|
||
return { label:'Gut 👃', color:'#4CAF50' };
|
||
if (feucht === 'trocken' && temp > 25)
|
||
return { label:'Schwach', color:'#94A3B8' };
|
||
return { label:'Mittel', color:'#F59E0B' };
|
||
}
|
||
|
||
function _schnueffelChip(d) {
|
||
const s = _schnueffelIndex(d);
|
||
return `
|
||
<span style="display:inline-flex;align-items:center;gap:4px;
|
||
font-size:var(--text-xs);border-radius:999px;
|
||
padding:3px 10px;background:${s.color}22;
|
||
border:1px solid ${s.color}55;color:${s.color};font-weight:600">
|
||
<svg class="ph-icon" style="width:12px;height:12px"><use href="/icons/phosphor.svg#nose"></use></svg>
|
||
Schnüffel: ${_esc(s.label)}
|
||
</span>
|
||
`;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// HUNDE-ALTER aus appState
|
||
// ----------------------------------------------------------
|
||
function _dogAgeYears() {
|
||
try {
|
||
const dog = _appState?.activeDog || _appState?.dog || _appState?.active_dog;
|
||
if (!dog) return null;
|
||
const geb = dog.geburtsdatum || dog.birthdate;
|
||
if (!geb) return null;
|
||
const birth = new Date(geb);
|
||
if (isNaN(birth)) return null;
|
||
const now = new Date();
|
||
let age = now.getFullYear() - birth.getFullYear();
|
||
const m = now.getMonth() - birth.getMonth();
|
||
if (m < 0 || (m === 0 && now.getDate() < birth.getDate())) age--;
|
||
return age < 0 ? 0 : age;
|
||
} catch { return null; }
|
||
}
|
||
|
||
function _dogAgeChip(ageYears) {
|
||
if (ageYears === null) return '';
|
||
if (ageYears < 1) {
|
||
return `
|
||
<div style="display:flex;align-items:flex-start;gap:var(--space-3);
|
||
padding:var(--space-3);border-radius:var(--radius);
|
||
background:#f59e0b1a;border:1px solid #f59e0b55;
|
||
margin-bottom:var(--space-3)">
|
||
<svg class="ph-icon" style="width:1.3rem;height:1.3rem;flex-shrink:0;color:#F59E0B"><use href="/icons/phosphor.svg#baby"></use></svg>
|
||
<div class="text-sm">
|
||
<strong>Welpe</strong> — kurze Spaziergänge, max. 15 Min bei Hitze.
|
||
Gelenke und Pfoten besonders schonen.
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
if (ageYears >= 8) {
|
||
return `
|
||
<div style="display:flex;align-items:flex-start;gap:var(--space-3);
|
||
padding:var(--space-3);border-radius:var(--radius);
|
||
background:#6b7280 1a;border:1px solid #6b728055;
|
||
margin-bottom:var(--space-3)">
|
||
<svg class="ph-icon" style="width:1.3rem;height:1.3rem;flex-shrink:0;color:#9CA3AF"><use href="/icons/phosphor.svg#person-simple-walk"></use></svg>
|
||
<div class="text-sm">
|
||
<strong>Seniorhund</strong> — Hitze und Kälte vermeiden, kurze Runden bevorzugen.
|
||
Auf Gelenkbeschwerden achten.
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// HILFSFUNKTIONEN — Wetter
|
||
// ----------------------------------------------------------
|
||
function _beaufort(kmh) {
|
||
if (kmh < 2) return 'Windstille';
|
||
if (kmh < 12) return 'leicht';
|
||
if (kmh < 29) return 'mäßig';
|
||
if (kmh < 50) return 'frisch';
|
||
if (kmh < 62) return 'stark';
|
||
if (kmh < 75) return 'stürmisch';
|
||
return 'Sturm';
|
||
}
|
||
|
||
function _uvLabel(uv) {
|
||
if (uv <= 2) return ['niedrig', '#4CAF50'];
|
||
if (uv <= 5) return ['mittel', '#FFC107'];
|
||
if (uv <= 7) return ['hoch', '#FF9800'];
|
||
if (uv <= 10) return ['sehr hoch', '#F44336'];
|
||
return ['extrem', '#9C27B0'];
|
||
}
|
||
|
||
function _compass(deg) {
|
||
const dirs = ['N','NO','O','SO','S','SW','W','NW'];
|
||
return dirs[Math.round(deg / 45) % 8];
|
||
}
|
||
|
||
function _asphaltLevel(temp) {
|
||
if (temp < 40) return ['Pfoten sicher', '#4CAF50', ''];
|
||
if (temp < 50) return ['leicht erwärmt', '#FFC107',
|
||
'Kurze Kontaktzeiten sind unbedenklich.'];
|
||
if (temp < 60) return ['Vorsicht — Pfoten schützen!', '#FF9800',
|
||
'Heiße Oberfläche! Auf Gras ausweichen oder Hundeschuhe verwenden.'];
|
||
return ['GEFAHR — Verbrennungsgefahr!', '#F44336',
|
||
'Asphalt kann Pfoten in Sekunden verbrennen. Spaziergang vermeiden!'];
|
||
}
|
||
|
||
function _pollenColor(level) {
|
||
if (level === 0) return '#9E9E9E';
|
||
if (level === 1) return '#4CAF50';
|
||
if (level === 2) return '#FFC107';
|
||
if (level === 3) return '#FF9800';
|
||
return '#F44336'; // level 4+
|
||
}
|
||
|
||
function _dogWeatherLabel(d, felltyp) {
|
||
const temp = d.temp_max ?? 20;
|
||
const tempMin = d.temp_min ?? temp;
|
||
const precip = d.precip_prob ?? 0;
|
||
const wind = d.wind_kmh ?? 0;
|
||
const asphalt = d.asphalt_temp ?? 0;
|
||
const wcode = d.weathercode ?? 0;
|
||
const isSnow = wcode >= 71 && wcode <= 77;
|
||
|
||
// Fell-spezifische Temperaturschwellen
|
||
const heatLimit = {
|
||
kurz: 25, mittel: 27, lang: 22, drahtaar: 26, doppel: 30, nackt: 20
|
||
}[felltyp] ?? 28;
|
||
const coldLimit = {
|
||
kurz: 8, mittel: 5, lang: 3, drahtaar: 5, doppel: -5, nackt: 15
|
||
}[felltyp] ?? 5;
|
||
|
||
if (d.thunderstorm)
|
||
return { label:'Gewitterangst-Wetter', sub:'Angsthasen lieber zu Hause lassen', emoji:'⛈️', color:'#7C3AED' };
|
||
if (isSnow && temp < 3)
|
||
return { label:'Schnee-Toben-Wetter', sub:'Pudel im Schnee — der Klassiker', emoji:'❄️', color:'#38BDF8' };
|
||
if (isSnow)
|
||
return { label:'Matschpfoten-Wetter', sub:'Pfoten nach der Runde gut abtrocknen', emoji:'🌨️', color:'#60A5FA' };
|
||
if (tempMin < 0 && precip < 30)
|
||
return { label:'Kristallklare Nasenluft', sub:'Kalt aber herrlich — Schnüffeln auf Maximum', emoji:'🌡️', color:'#60A5FA' };
|
||
if (temp < coldLimit && precip > 50)
|
||
return { label:'Kuschelwetter', sub:'Kurze Runde, dann ab auf das Sofa', emoji:'🏠', color:'#6B7280' };
|
||
if (temp < coldLimit)
|
||
return { label:'Fellkuschelwetter', sub:'Frisch und klar — ideal für aktive Rassen', emoji:'🧣', color:'#93C5FD' };
|
||
if (temp > heatLimit && asphalt > 50)
|
||
return { label:'Pfoten-Alarm!', sub:'Asphalt zu heiß — früh morgens oder abends raus', emoji:'🔥', color:'#EF4444' };
|
||
if (temp > heatLimit)
|
||
return { label:'Schwimm-Wetter', sub:'Bach oder See suchen — Hunde überhitzen schnell', emoji:'🏊', color:'#F97316' };
|
||
if (precip > 70 && temp < 15)
|
||
return { label:'Nass-Hund-Wetter', sub:'Handtuch bereit? Der Geruch kommt garantiert', emoji:'💧', color:'#3B82F6' };
|
||
if (precip > 70)
|
||
return { label:'Warm-Dusch-Wetter', sub:'Wer braucht noch ein Bad — der Regen übernimmt', emoji:'🌧️', color:'#60A5FA' };
|
||
if (precip > 30 && temp >= 10 && temp <= 20)
|
||
return { label:'Schnüffel-Wetter', sub:'Feuchte Luft = Nasenarbeit pur — Gerüche lieben das', emoji:'👃', color:'#34D399' };
|
||
if (wind > 50)
|
||
return { label:'Sturmfrisur-Wetter', sub:'Fell in alle Richtungen — Leine gut festhalten', emoji:'🌬️', color:'#A78BFA' };
|
||
if (wind > 30 && temp >= 15)
|
||
return { label:'Ohren-im-Wind-Wetter', sub:'Optimal für Hunde mit Schlappohren', emoji:'💨', color:'#A78BFA' };
|
||
if (precip > 30 && precip <= 70)
|
||
return { label:'Gassiregen-Wetter', sub:'Leichte Jacke, kurze Runde — Hund findet es gut', emoji:'🌦️', color:'#60A5FA' };
|
||
if (temp >= 18 && temp <= 26 && precip < 20)
|
||
return { label:'Perfektes Gassi-Wetter',sub:'Heute müssen alle Routen genossen werden', emoji:'🐾', color:'#10B981' };
|
||
if (temp >= 10 && temp < 18 && precip < 30)
|
||
return { label:'Klassisches Hunde-Wetter', sub:'Nicht zu warm, nicht zu kalt — Vierbeiner-Paradies', emoji:'🐕', color:'#4CAF50' };
|
||
return { label:'Gutes Hunde-Wetter', sub:'Raus mit dem Hund!', emoji:'🐶', color:'#10B981' };
|
||
}
|
||
|
||
function _tickLevel(risk) {
|
||
const r = (risk || '').toLowerCase();
|
||
if (r === 'niedrig') return ['niedrig', '#4CAF50'];
|
||
if (r === 'mittel') return ['mittel', '#FF9800'];
|
||
return ['hoch', '#F44336'];
|
||
}
|
||
|
||
function _esc(s) {
|
||
if (s == null) return '';
|
||
return String(s)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// MEINE WETTERREKORDE
|
||
// ----------------------------------------------------------
|
||
async function _loadRecords() {
|
||
// Nur wenn User eingeloggt
|
||
if (!_appState?.user) return;
|
||
// Nur einmal pro Seitenaufruf laden
|
||
if (_recordsLoaded) return;
|
||
_recordsLoaded = true;
|
||
try {
|
||
const res = await API.get('/weather/records');
|
||
_renderRecords(res?.records || null);
|
||
} catch {
|
||
// Stumm scheitern — Rekorde sind ein Nice-to-have
|
||
}
|
||
}
|
||
|
||
function _fmtDate(datum) {
|
||
if (!datum) return '';
|
||
try {
|
||
return new Date(datum + 'T12:00').toLocaleDateString('de', {
|
||
day: 'numeric', month: 'short', year: 'numeric'
|
||
});
|
||
} catch { return datum; }
|
||
}
|
||
|
||
function _recordCard(emoji, title, value, subtitle, color) {
|
||
return `
|
||
<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-lg);font-weight:800;color:${color};line-height:1.1">
|
||
${_esc(value)}
|
||
</div>
|
||
<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>
|
||
`;
|
||
}
|
||
|
||
function _renderRecords(records) {
|
||
const el = _container.querySelector('#wttr-records');
|
||
if (!el) return;
|
||
|
||
// Mindestens 3 Einträge nötig
|
||
if (!records || (records.gesamt_eintraege || 0) < 3) {
|
||
el.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
const cards = [];
|
||
|
||
if (records.kaeltester) {
|
||
const e = records.kaeltester;
|
||
const sub = e.titel ? `${e.titel} · ${_fmtDate(e.datum)}` : _fmtDate(e.datum);
|
||
cards.push(_recordCard('🥶', 'Kältester Gassi', `${Math.round(e.temp_c)}°C`, sub, '#60A5FA'));
|
||
}
|
||
|
||
if (records.heissester) {
|
||
const e = records.heissester;
|
||
const sub = e.titel ? `${e.titel} · ${_fmtDate(e.datum)}` : _fmtDate(e.datum);
|
||
cards.push(_recordCard('🔥', 'Heißester Gassi', `${Math.round(e.temp_c)}°C`, sub, '#EF4444'));
|
||
}
|
||
|
||
if (records.stuermischster) {
|
||
const e = records.stuermischster;
|
||
const sub = e.titel ? `${e.titel} · ${_fmtDate(e.datum)}` : _fmtDate(e.datum);
|
||
cards.push(_recordCard('🌬️', 'Stürmischster Tag', `${Math.round(e.wind_kmh)} km/h`, sub, '#A78BFA'));
|
||
}
|
||
|
||
const regenCount = records.regen_eintraege || 0;
|
||
const gesamt = records.gesamt_eintraege || 0;
|
||
cards.push(_recordCard('💧', 'Regentage', `${regenCount} Einträge`, `von ${gesamt} Tagebucheinträgen`, '#3B82F6'));
|
||
|
||
el.innerHTML = `
|
||
<div style="margin-top:var(--space-5)">
|
||
<h3 style="font-size:var(--text-base);font-weight:700;
|
||
margin-bottom:var(--space-3);
|
||
display:flex;align-items:center;gap:var(--space-2)">
|
||
<svg class="ph-icon" style="width:1.1em;height:1.1em;vertical-align:-2px;color:var(--c-primary)">
|
||
<use href="/icons/phosphor.svg#trophy"></use>
|
||
</svg>
|
||
Meine Wetterrekorde
|
||
</h3>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2)">
|
||
${cards.join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// PUBLIC API
|
||
// ----------------------------------------------------------
|
||
return { init, refresh };
|
||
|
||
})();
|