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:
parent
d9626108e6
commit
2f56be835e
13 changed files with 379 additions and 36 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue