Add: München als 3. Wetter-Location + Wetter-Detail-Modal

- München als tertiärer Standort (iris-Akzent) hinzugefügt
- Klick auf WeatherCard öffnet Detail-Modal mit:
  - 24h stündliche Prognose (horizontal scrollbar)
  - 7-Tage-Vorhersage mit Temperaturbalken
  - Wind, Feuchte, Sonnenauf/-untergang
- Backend: 7-Tage statt 3-Tage Forecast, 24 Hourly-Slots pro Standort
- Backend: forecast_3day → forecast Feldname-Konsistenz
- Dashboard: 3-Spalten Wetter-Grid statt 4 (HourlyForecast → Modal)
- Admin: Tertiärer Standort konfigurierbar
- THERMAL Design: iris glow, modal animation, Portal-basiertes Modal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sam 2026-03-03 01:13:49 +01:00
parent d9626108e6
commit 2f56be835e
13 changed files with 379 additions and 36 deletions

View file

@ -37,6 +37,7 @@ class Settings:
# --- Weather ---
weather_location: str = "Leverkusen"
weather_location_secondary: str = "Rab,Croatia"
weather_location_tertiary: str = "München"
weather_cache_ttl: int = 1800
# --- Home Assistant ---
@ -94,6 +95,7 @@ class Settings:
# Legacy ENV support — used for first-run seeding
s.weather_location = os.getenv("WEATHER_LOCATION", s.weather_location)
s.weather_location_secondary = os.getenv("WEATHER_LOCATION_SECONDARY", s.weather_location_secondary)
s.weather_location_tertiary = os.getenv("WEATHER_LOCATION_TERTIARY", s.weather_location_tertiary)
s.ha_url = os.getenv("HA_URL", s.ha_url)
s.ha_token = os.getenv("HA_TOKEN", s.ha_token)
s.ha_enabled = bool(s.ha_url)
@ -151,6 +153,7 @@ class Settings:
if itype == "weather":
self.weather_location = cfg.get("location", self.weather_location)
self.weather_location_secondary = cfg.get("location_secondary", self.weather_location_secondary)
self.weather_location_tertiary = cfg.get("location_tertiary", self.weather_location_tertiary)
elif itype == "news":
self.news_max_age_hours = int(cfg.get("max_age_hours", self.news_max_age_hours))

View file

@ -16,6 +16,9 @@ CREATE INDEX IF NOT EXISTS idx_market_news_published
CREATE INDEX IF NOT EXISTS idx_market_news_category
ON market_news (category);
CREATE UNIQUE INDEX IF NOT EXISTS idx_market_news_url_unique
ON market_news (url);
-- Record this migration
INSERT INTO schema_version (version, description)
VALUES (2, 'market_news table for n8n-sourced articles')

View file

@ -1,4 +1,4 @@
"""Weather data router -- primary + secondary locations and hourly forecast."""
"""Weather data router -- primary, secondary & tertiary locations with hourly forecasts."""
from __future__ import annotations
@ -31,29 +31,41 @@ async def get_weather() -> Dict[str, Any]:
# --- cache miss -- fetch all three in parallel ----------------------------
cfg = get_settings()
logger.info("[WEATHER] Cache miss — fetching '%s' + '%s'",
cfg.weather_location, cfg.weather_location_secondary)
logger.info("[WEATHER] Cache miss — fetching '%s' + '%s' + '%s'",
cfg.weather_location, cfg.weather_location_secondary,
cfg.weather_location_tertiary)
results = await asyncio.gather(
_safe_fetch_weather(cfg.weather_location),
_safe_fetch_weather(cfg.weather_location_secondary),
_safe_fetch_hourly(cfg.weather_location),
_safe_fetch_weather(cfg.weather_location_tertiary),
_safe_fetch_hourly(cfg.weather_location, max_slots=24),
_safe_fetch_hourly(cfg.weather_location_secondary, max_slots=24),
_safe_fetch_hourly(cfg.weather_location_tertiary, max_slots=24),
return_exceptions=False,
)
primary_data = results[0]
secondary_data = results[1]
hourly_data = results[2]
tertiary_data = results[2]
hourly_data = results[3]
hourly_secondary = results[4]
hourly_tertiary = results[5]
# Log result summary
_log_weather_result("primary", cfg.weather_location, primary_data)
_log_weather_result("secondary", cfg.weather_location_secondary, secondary_data)
logger.info("[WEATHER] Hourly: %d slots", len(hourly_data))
_log_weather_result("tertiary", cfg.weather_location_tertiary, tertiary_data)
logger.info("[WEATHER] Hourly: %d + %d + %d slots",
len(hourly_data), len(hourly_secondary), len(hourly_tertiary))
payload: Dict[str, Any] = {
"primary": primary_data,
"secondary": secondary_data,
"tertiary": tertiary_data,
"hourly": hourly_data,
"hourly_secondary": hourly_secondary,
"hourly_tertiary": hourly_tertiary,
}
await cache.set(CACHE_KEY, payload, cfg.weather_cache_ttl)
@ -83,10 +95,10 @@ async def _safe_fetch_weather(location: str) -> Dict[str, Any]:
return {"error": True, "message": str(exc), "location": location}
async def _safe_fetch_hourly(location: str) -> List[Dict[str, Any]]:
async def _safe_fetch_hourly(location: str, max_slots: int = 8) -> List[Dict[str, Any]]:
"""Fetch hourly forecast for *location*, returning ``[]`` on failure."""
try:
return await fetch_hourly_forecast(location)
return await fetch_hourly_forecast(location, max_slots=max_slots)
except Exception as exc:
logger.exception("[WEATHER] Unhandled error fetching hourly for '%s'", location)
return []

View file

@ -44,6 +44,7 @@ async def seed_if_empty() -> None:
"config": {
"location": os.getenv("WEATHER_LOCATION", "Leverkusen"),
"location_secondary": os.getenv("WEATHER_LOCATION_SECONDARY", "Rab,Croatia"),
"location_tertiary": os.getenv("WEATHER_LOCATION_TERTIARY", "München"),
},
"enabled": True,
"display_order": 0,

View file

@ -213,7 +213,7 @@ async def fetch_weather(location: str) -> Dict[str, Any]:
"wind_kmh": 0,
"description": "Nicht verfügbar",
"icon": "",
"forecast_3day": [],
"forecast": [],
"error": None,
}
@ -235,7 +235,7 @@ async def fetch_weather(location: str) -> Dict[str, Any]:
"current": "temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m",
"daily": "weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset",
"timezone": geo["timezone"],
"forecast_days": 3,
"forecast_days": 7,
},
)
resp.raise_for_status()
@ -266,36 +266,42 @@ async def fetch_weather(location: str) -> Dict[str, Any]:
"error": None,
}
# Step 4: Parse 3-day forecast
# Step 4: Parse 7-day forecast
daily = data.get("daily", {})
dates = daily.get("time", [])
forecast_3day: List[Dict[str, Any]] = []
forecast: List[Dict[str, Any]] = []
for i, date_str in enumerate(dates[:3]):
code = int(daily.get("weather_code", [0] * 3)[i])
forecast_3day.append({
for i, date_str in enumerate(dates):
codes_list = daily.get("weather_code", [])
code = int(codes_list[i]) if i < len(codes_list) else 0
max_temps = daily.get("temperature_2m_max", [])
min_temps = daily.get("temperature_2m_min", [])
sunrises = daily.get("sunrise", [])
sunsets = daily.get("sunset", [])
forecast.append({
"date": date_str,
"max_temp": round(daily.get("temperature_2m_max", [0] * 3)[i]),
"min_temp": round(daily.get("temperature_2m_min", [0] * 3)[i]),
"max_temp": round(max_temps[i]) if i < len(max_temps) else 0,
"min_temp": round(min_temps[i]) if i < len(min_temps) else 0,
"icon": _wmo_icon(code),
"description": _wmo_description(code),
"sunrise": daily.get("sunrise", [""] * 3)[i].split("T")[-1] if daily.get("sunrise") else "",
"sunset": daily.get("sunset", [""] * 3)[i].split("T")[-1] if daily.get("sunset") else "",
"sunrise": sunrises[i].split("T")[-1] if i < len(sunrises) and sunrises[i] else "",
"sunset": sunsets[i].split("T")[-1] if i < len(sunsets) and sunsets[i] else "",
})
result["forecast_3day"] = forecast_3day
result["forecast"] = forecast
logger.info("[WEATHER] Fetched '%s': %d°C, %s", geo["name"], result["temp"], result["description"])
return result
async def fetch_hourly_forecast(location: str) -> List[Dict[str, Any]]:
"""Fetch hourly forecast for the next 8 hours from Open-Meteo.
async def fetch_hourly_forecast(location: str, max_slots: int = 8) -> List[Dict[str, Any]]:
"""Fetch hourly forecast from Open-Meteo.
Args:
location: City name or coordinates.
max_slots: Maximum number of hourly slots to return.
Returns:
List of hourly forecast dicts (max 8 entries).
List of hourly forecast dicts.
"""
geo = await _geocode(location)
if geo is None:
@ -354,7 +360,7 @@ async def fetch_hourly_forecast(location: str) -> List[Dict[str, Any]]:
"wind_kmh": round(winds[i]) if i < len(winds) else 0,
})
if len(upcoming) >= 8:
if len(upcoming) >= max_slots:
break
logger.debug("[WEATHER] Hourly for '%s': %d slots", location, len(upcoming))