diff --git a/server/config.py b/server/config.py index cb388f9..8d8ac61 100644 --- a/server/config.py +++ b/server/config.py @@ -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)) diff --git a/server/migrations/002_market_news.sql b/server/migrations/002_market_news.sql index 4ca16f3..045020b 100644 --- a/server/migrations/002_market_news.sql +++ b/server/migrations/002_market_news.sql @@ -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') diff --git a/server/routers/weather.py b/server/routers/weather.py index f0851ae..99df4fa 100644 --- a/server/routers/weather.py +++ b/server/routers/weather.py @@ -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 [] diff --git a/server/services/seed_service.py b/server/services/seed_service.py index d59d26a..d2aa3ad 100644 --- a/server/services/seed_service.py +++ b/server/services/seed_service.py @@ -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, diff --git a/server/services/weather_service.py b/server/services/weather_service.py index bf9cad9..cfad0bd 100644 --- a/server/services/weather_service.py +++ b/server/services/weather_service.py @@ -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)) diff --git a/web/src/admin/pages/WeatherSettings.tsx b/web/src/admin/pages/WeatherSettings.tsx index 2312a5e..6e3efca 100644 --- a/web/src/admin/pages/WeatherSettings.tsx +++ b/web/src/admin/pages/WeatherSettings.tsx @@ -68,6 +68,14 @@ export default function WeatherSettings() { setConfig("secondary_location", v)} + placeholder="z.B. Rab,Croatia oder 44.76,14.76" + /> + + + + setConfig("tertiary_location", v)} placeholder="z.B. München oder 48.137,11.576" /> diff --git a/web/src/api.ts b/web/src/api.ts index 016d298..b1cf8c8 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -32,7 +32,10 @@ export interface HourlySlot { export interface WeatherResponse { primary: WeatherData; secondary: WeatherData; + tertiary: WeatherData; hourly: HourlySlot[]; + hourly_secondary: HourlySlot[]; + hourly_tertiary: HourlySlot[]; } export interface NewsArticle { diff --git a/web/src/components/WeatherCard.tsx b/web/src/components/WeatherCard.tsx index b0f5a2a..d0912f4 100644 --- a/web/src/components/WeatherCard.tsx +++ b/web/src/components/WeatherCard.tsx @@ -3,15 +3,17 @@ import type { WeatherData } from "../api"; interface WeatherCardProps { data: WeatherData; - accent: "cyan" | "amber"; + accent: "cyan" | "amber" | "iris"; + onClick?: () => void; } const ACCENT = { cyan: { strip: "gold", text: "text-gold", glow: "data-glow-gold", tag: "border-gold/30 text-gold bg-gold/5" }, amber: { strip: "mint", text: "text-mint", glow: "data-glow-mint", tag: "border-mint/30 text-mint bg-mint/5" }, + iris: { strip: "iris", text: "text-iris", glow: "data-glow-iris", tag: "border-iris/30 text-iris bg-iris/5" }, } as const; -export default function WeatherCard({ data, accent }: WeatherCardProps) { +export default function WeatherCard({ data, accent, onClick }: WeatherCardProps) { const a = ACCENT[accent]; if (data.error) { @@ -29,7 +31,14 @@ export default function WeatherCard({ data, accent }: WeatherCardProps) { } return ( -
+
e.key === "Enter" && onClick?.()} + > {/* Location tag */}
{data.location} diff --git a/web/src/components/WeatherDetailModal.tsx b/web/src/components/WeatherDetailModal.tsx new file mode 100644 index 0000000..c803e5e --- /dev/null +++ b/web/src/components/WeatherDetailModal.tsx @@ -0,0 +1,223 @@ +import { useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; +import { X, Droplets, Wind, Sunrise, Sunset } from "lucide-react"; +import type { WeatherData, HourlySlot } from "../api"; + +interface WeatherDetailModalProps { + weather: WeatherData; + hourly: HourlySlot[]; + onClose: () => void; + accentColor: "gold" | "mint" | "iris"; +} + +const ACCENT_CLASSES = { + gold: { text: "text-gold", glow: "data-glow-gold", bg: "bg-gold", strip: "gold", bar: "bg-gold/70" }, + mint: { text: "text-mint", glow: "data-glow-mint", bg: "bg-mint", strip: "mint", bar: "bg-mint/70" }, + iris: { text: "text-iris", glow: "data-glow-iris", bg: "bg-iris", strip: "iris", bar: "bg-iris/70" }, +} as const; + +const DAY_NAMES = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"]; + +export default function WeatherDetailModal({ weather, hourly, onClose, accentColor }: WeatherDetailModalProps) { + const closeRef = useRef(null); + const a = ACCENT_CLASSES[accentColor]; + + // Focus close button on mount, ESC to close + useEffect(() => { + closeRef.current?.focus(); + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [onClose]); + + // Prevent body scroll while modal is open + useEffect(() => { + document.body.style.overflow = "hidden"; + return () => { document.body.style.overflow = ""; }; + }, []); + + // Calculate global min/max for temperature range bars + const forecast = weather.forecast || []; + const allTemps = forecast.flatMap((d) => [d.min_temp, d.max_temp]); + const globalMin = allTemps.length ? Math.min(...allTemps) : 0; + const globalMax = allTemps.length ? Math.max(...allTemps) : 20; + const tempRange = globalMax - globalMin || 1; + + // Today's sunrise/sunset from forecast + const today = forecast[0]; + + return createPortal( +
{ if (e.target === e.currentTarget) onClose(); }} + role="dialog" + aria-modal="true" + aria-label={`Wetter ${weather.location}`} + > +
+ {/* Header */} +
+
+

+ {weather.location} +

+

{weather.description}

+
+ +
+
+
+ + {Math.round(weather.temp)} + + ° +
+
+ {weather.icon} +
+ + +
+ + {/* Quick stats */} +
+ } label="Wind" value={`${Math.round(weather.wind_kmh)} km/h`} /> + } label="Feuchte" value={`${weather.humidity}%`} /> + {today?.sunrise && ( + } label="Aufgang" value={today.sunrise} /> + )} + {today?.sunset && ( + } label="Untergang" value={today.sunset} /> + )} +
+ + {/* Hourly forecast */} + {hourly.length > 0 && ( +
+
+ Stündlich + +
+ +
+ {hourly.map((slot, i) => ( +
+ + {i === 0 ? "Jetzt" : slot.time} + + {slot.icon} + + {Math.round(slot.temp)}° + + {slot.precip_chance > 0 && ( + + {slot.precip_chance}% + + )} +
+ ))} +
+
+ )} + + {/* Multi-day forecast */} + {forecast.length > 0 && ( +
+
+ 7 Tage + +
+ +
+ {forecast.map((day, i) => { + const date = new Date(day.date); + const dayName = DAY_NAMES[date.getDay()]; + const dateStr = `${String(date.getDate()).padStart(2, "0")}.${String(date.getMonth() + 1).padStart(2, "0")}`; + const barLeft = ((day.min_temp - globalMin) / tempRange) * 100; + const barWidth = ((day.max_temp - day.min_temp) / tempRange) * 100; + + return ( +
+ {/* Day + date */} + + {i === 0 ? "Heu" : dayName} + + {dateStr} + + {/* Icon */} + {day.icon} + + {/* Min temp */} + + {day.min_temp}° + + + {/* Temperature range bar */} +
+
+
+ + {/* Max temp */} + + {day.max_temp}° + +
+ ); + })} +
+
+ )} +
+
, + document.body, + ); +} + +function MiniStat({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { + return ( +
+ {icon} + {value} + {label} +
+ ); +} diff --git a/web/src/mockData.ts b/web/src/mockData.ts index 21ab9d4..f4c09c4 100644 --- a/web/src/mockData.ts +++ b/web/src/mockData.ts @@ -32,6 +32,20 @@ export const MOCK_DATA: DashboardData = { { date: "2026-03-04", max_temp: 17, min_temp: 9, icon: "⛅", description: "Partly cloudy" }, ], }, + tertiary: { + location: "München", + temp: 5, + feels_like: 2, + humidity: 65, + wind_kmh: 14, + description: "Bewölkt", + icon: "☁️", + forecast: [ + { date: "2026-03-02", max_temp: 7, min_temp: 0, icon: "☁️", description: "Bewölkt" }, + { date: "2026-03-03", max_temp: 9, min_temp: 2, icon: "🌤️", description: "Überwiegend klar" }, + { date: "2026-03-04", max_temp: 5, min_temp: -1, icon: "❄️", description: "Leichter Schneefall" }, + ], + }, hourly: [ { time: "14:00", temp: 8, icon: "⛅", precip_chance: 10 }, { time: "15:00", temp: 9, icon: "🌤️", precip_chance: 5 }, @@ -42,6 +56,26 @@ export const MOCK_DATA: DashboardData = { { time: "20:00", temp: 4, icon: "🌧️", precip_chance: 60 }, { time: "21:00", temp: 4, icon: "☁️", precip_chance: 35 }, ], + hourly_secondary: [ + { time: "14:00", temp: 16, icon: "☀️", precip_chance: 0 }, + { time: "15:00", temp: 17, icon: "☀️", precip_chance: 0 }, + { time: "16:00", temp: 16, icon: "🌤️", precip_chance: 5 }, + { time: "17:00", temp: 15, icon: "🌤️", precip_chance: 5 }, + { time: "18:00", temp: 14, icon: "⛅", precip_chance: 10 }, + { time: "19:00", temp: 13, icon: "☁️", precip_chance: 15 }, + { time: "20:00", temp: 12, icon: "☁️", precip_chance: 10 }, + { time: "21:00", temp: 11, icon: "🌤️", precip_chance: 5 }, + ], + hourly_tertiary: [ + { time: "14:00", temp: 5, icon: "☁️", precip_chance: 20 }, + { time: "15:00", temp: 6, icon: "☁️", precip_chance: 25 }, + { time: "16:00", temp: 5, icon: "🌧️", precip_chance: 40 }, + { time: "17:00", temp: 4, icon: "🌧️", precip_chance: 50 }, + { time: "18:00", temp: 3, icon: "☁️", precip_chance: 30 }, + { time: "19:00", temp: 2, icon: "☁️", precip_chance: 20 }, + { time: "20:00", temp: 1, icon: "☁️", precip_chance: 15 }, + { time: "21:00", temp: 0, icon: "❄️", precip_chance: 35 }, + ], }, news: { articles: [ diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 5110db9..72315d1 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -1,8 +1,9 @@ +import { useState } from "react"; import { Link } from "react-router-dom"; import { useDashboard } from "../hooks/useDashboard"; import Clock from "../components/Clock"; import WeatherCard from "../components/WeatherCard"; -import HourlyForecast from "../components/HourlyForecast"; +import WeatherDetailModal from "../components/WeatherDetailModal"; import NewsGrid from "../components/NewsGrid"; import ServerCard from "../components/ServerCard"; import HomeAssistant from "../components/HomeAssistant"; @@ -10,8 +11,29 @@ import TasksCard from "../components/TasksCard"; import MqttCard from "../components/MqttCard"; import { RefreshCw, AlertTriangle, Settings } from "lucide-react"; +type WeatherLocationKey = "primary" | "secondary" | "tertiary"; + +const WEATHER_ACCENTS: Record = { + primary: "cyan", + secondary: "amber", + tertiary: "iris", +}; + +const WEATHER_MODAL_COLORS: Record = { + primary: "gold", + secondary: "mint", + tertiary: "iris", +}; + +const HOURLY_KEYS: Record = { + primary: "hourly", + secondary: "hourly_secondary", + tertiary: "hourly_tertiary", +}; + export default function Dashboard() { const { data, loading, error, refresh } = useDashboard(); + const [weatherModal, setWeatherModal] = useState(null); return (
@@ -74,13 +96,26 @@ export default function Dashboard() {
-
- - -
- -
+
+ {(["primary", "secondary", "tertiary"] as const).map((key) => ( + setWeatherModal(key)} + /> + ))}
+ + {/* Weather detail modal */} + {weatherModal && ( + setWeatherModal(null)} + /> + )} {/* Section 02 — Infrastruktur */} @@ -157,8 +192,8 @@ function LoadingSkeleton() {
-
- {Array.from({ length: 4 }).map((_, i) => ( +
+ {Array.from({ length: 3 }).map((_, i) => ( ))}
diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 571c2df..f118fb4 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -127,6 +127,7 @@ .data-glow-gold { text-shadow: 0 0 20px #e8a44a33, 0 0 40px #e8a44a15; } .data-glow-mint { text-shadow: 0 0 20px #4ae8a833, 0 0 40px #4ae8a815; } .data-glow-cherry { text-shadow: 0 0 20px #e85a5a33, 0 0 40px #e85a5a15; } + .data-glow-iris { text-shadow: 0 0 20px #a87aec33, 0 0 40px #a87aec15; } /* ── Tags/Badges ─────────────────────────────────────────── */ .tag { diff --git a/web/tailwind.config.js b/web/tailwind.config.js index eabf8ad..6cdd185 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -54,6 +54,7 @@ export default { "slide-in": "slideIn 0.3s ease-out both", "scan": "scan 1.5s ease-in-out infinite", "glow-pulse": "glowPulse 2s ease-in-out infinite", + "modal-in": "modalIn 0.25s ease-out both", }, keyframes: { fadeIn: { @@ -72,6 +73,10 @@ export default { "0%, 100%": { opacity: "0.5" }, "50%": { opacity: "1" }, }, + modalIn: { + "0%": { opacity: "0", transform: "scale(0.95) translateY(8px)" }, + "100%": { opacity: "1", transform: "scale(1) translateY(0)" }, + }, }, }, },