banyaro/backend/static/js/pages/wetter.js
rene 459cd425f2 Design-System Sprint A: utilities.css + 948 Inline-Styles → Utility-Klassen, SW by-v1102
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).
2026-05-27 07:11:27 +02:00

1189 lines
49 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
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 &amp; 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 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 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 &amp; loslegen
</button>
${!isLoggedIn ? `
<button class="btn btn-secondary" id="wttr-btn-login"
style="display:flex;align-items:center;justify-content:center;gap:var(--space-2)">
<svg class="ph-icon" style="width:1rem;height:1rem">
<use href="/icons/phosphor.svg#user"></use>
</svg>
Kostenlos registrieren
</button>
<p style="text-align:center;font-size:var(--text-xs);color:var(--c-text-secondary);margin:0">
Mit Account werden Rekorde &amp; Gassi-Score für deinen Hund gespeichert.
</p>
` : ''}
</div>
</div>
`;
body.querySelector('#wttr-btn-retry')?.addEventListener('click', () => {
_renderShell();
_tryAutoLocate();
});
body.querySelector('#wttr-btn-login')?.addEventListener('click', () => {
if (window.App) App.navigate('settings');
});
}
// ----------------------------------------------------------
// 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 (110)
// ----------------------------------------------------------
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: 1020°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">/&nbsp;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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// 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 };
})();