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 ---
|
||||||
weather_location: str = "Leverkusen"
|
weather_location: str = "Leverkusen"
|
||||||
weather_location_secondary: str = "Rab,Croatia"
|
weather_location_secondary: str = "Rab,Croatia"
|
||||||
|
weather_location_tertiary: str = "München"
|
||||||
weather_cache_ttl: int = 1800
|
weather_cache_ttl: int = 1800
|
||||||
|
|
||||||
# --- Home Assistant ---
|
# --- Home Assistant ---
|
||||||
|
|
@ -94,6 +95,7 @@ class Settings:
|
||||||
# Legacy ENV support — used for first-run seeding
|
# Legacy ENV support — used for first-run seeding
|
||||||
s.weather_location = os.getenv("WEATHER_LOCATION", s.weather_location)
|
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_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_url = os.getenv("HA_URL", s.ha_url)
|
||||||
s.ha_token = os.getenv("HA_TOKEN", s.ha_token)
|
s.ha_token = os.getenv("HA_TOKEN", s.ha_token)
|
||||||
s.ha_enabled = bool(s.ha_url)
|
s.ha_enabled = bool(s.ha_url)
|
||||||
|
|
@ -151,6 +153,7 @@ class Settings:
|
||||||
if itype == "weather":
|
if itype == "weather":
|
||||||
self.weather_location = cfg.get("location", self.weather_location)
|
self.weather_location = cfg.get("location", self.weather_location)
|
||||||
self.weather_location_secondary = cfg.get("location_secondary", self.weather_location_secondary)
|
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":
|
elif itype == "news":
|
||||||
self.news_max_age_hours = int(cfg.get("max_age_hours", self.news_max_age_hours))
|
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
|
CREATE INDEX IF NOT EXISTS idx_market_news_category
|
||||||
ON 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
|
-- Record this migration
|
||||||
INSERT INTO schema_version (version, description)
|
INSERT INTO schema_version (version, description)
|
||||||
VALUES (2, 'market_news table for n8n-sourced articles')
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -31,29 +31,41 @@ async def get_weather() -> Dict[str, Any]:
|
||||||
|
|
||||||
# --- cache miss -- fetch all three in parallel ----------------------------
|
# --- cache miss -- fetch all three in parallel ----------------------------
|
||||||
cfg = get_settings()
|
cfg = get_settings()
|
||||||
logger.info("[WEATHER] Cache miss — fetching '%s' + '%s'",
|
logger.info("[WEATHER] Cache miss — fetching '%s' + '%s' + '%s'",
|
||||||
cfg.weather_location, cfg.weather_location_secondary)
|
cfg.weather_location, cfg.weather_location_secondary,
|
||||||
|
cfg.weather_location_tertiary)
|
||||||
|
|
||||||
results = await asyncio.gather(
|
results = await asyncio.gather(
|
||||||
_safe_fetch_weather(cfg.weather_location),
|
_safe_fetch_weather(cfg.weather_location),
|
||||||
_safe_fetch_weather(cfg.weather_location_secondary),
|
_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,
|
return_exceptions=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
primary_data = results[0]
|
primary_data = results[0]
|
||||||
secondary_data = results[1]
|
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 result summary
|
||||||
_log_weather_result("primary", cfg.weather_location, primary_data)
|
_log_weather_result("primary", cfg.weather_location, primary_data)
|
||||||
_log_weather_result("secondary", cfg.weather_location_secondary, secondary_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] = {
|
payload: Dict[str, Any] = {
|
||||||
"primary": primary_data,
|
"primary": primary_data,
|
||||||
"secondary": secondary_data,
|
"secondary": secondary_data,
|
||||||
|
"tertiary": tertiary_data,
|
||||||
"hourly": hourly_data,
|
"hourly": hourly_data,
|
||||||
|
"hourly_secondary": hourly_secondary,
|
||||||
|
"hourly_tertiary": hourly_tertiary,
|
||||||
}
|
}
|
||||||
|
|
||||||
await cache.set(CACHE_KEY, payload, cfg.weather_cache_ttl)
|
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}
|
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."""
|
"""Fetch hourly forecast for *location*, returning ``[]`` on failure."""
|
||||||
try:
|
try:
|
||||||
return await fetch_hourly_forecast(location)
|
return await fetch_hourly_forecast(location, max_slots=max_slots)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("[WEATHER] Unhandled error fetching hourly for '%s'", location)
|
logger.exception("[WEATHER] Unhandled error fetching hourly for '%s'", location)
|
||||||
return []
|
return []
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ async def seed_if_empty() -> None:
|
||||||
"config": {
|
"config": {
|
||||||
"location": os.getenv("WEATHER_LOCATION", "Leverkusen"),
|
"location": os.getenv("WEATHER_LOCATION", "Leverkusen"),
|
||||||
"location_secondary": os.getenv("WEATHER_LOCATION_SECONDARY", "Rab,Croatia"),
|
"location_secondary": os.getenv("WEATHER_LOCATION_SECONDARY", "Rab,Croatia"),
|
||||||
|
"location_tertiary": os.getenv("WEATHER_LOCATION_TERTIARY", "München"),
|
||||||
},
|
},
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"display_order": 0,
|
"display_order": 0,
|
||||||
|
|
|
||||||
|
|
@ -213,7 +213,7 @@ async def fetch_weather(location: str) -> Dict[str, Any]:
|
||||||
"wind_kmh": 0,
|
"wind_kmh": 0,
|
||||||
"description": "Nicht verfügbar",
|
"description": "Nicht verfügbar",
|
||||||
"icon": "❓",
|
"icon": "❓",
|
||||||
"forecast_3day": [],
|
"forecast": [],
|
||||||
"error": None,
|
"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",
|
"current": "temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m",
|
||||||
"daily": "weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset",
|
"daily": "weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset",
|
||||||
"timezone": geo["timezone"],
|
"timezone": geo["timezone"],
|
||||||
"forecast_days": 3,
|
"forecast_days": 7,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|
@ -266,36 +266,42 @@ async def fetch_weather(location: str) -> Dict[str, Any]:
|
||||||
"error": None,
|
"error": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Step 4: Parse 3-day forecast
|
# Step 4: Parse 7-day forecast
|
||||||
daily = data.get("daily", {})
|
daily = data.get("daily", {})
|
||||||
dates = daily.get("time", [])
|
dates = daily.get("time", [])
|
||||||
forecast_3day: List[Dict[str, Any]] = []
|
forecast: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
for i, date_str in enumerate(dates[:3]):
|
for i, date_str in enumerate(dates):
|
||||||
code = int(daily.get("weather_code", [0] * 3)[i])
|
codes_list = daily.get("weather_code", [])
|
||||||
forecast_3day.append({
|
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,
|
"date": date_str,
|
||||||
"max_temp": round(daily.get("temperature_2m_max", [0] * 3)[i]),
|
"max_temp": round(max_temps[i]) if i < len(max_temps) else 0,
|
||||||
"min_temp": round(daily.get("temperature_2m_min", [0] * 3)[i]),
|
"min_temp": round(min_temps[i]) if i < len(min_temps) else 0,
|
||||||
"icon": _wmo_icon(code),
|
"icon": _wmo_icon(code),
|
||||||
"description": _wmo_description(code),
|
"description": _wmo_description(code),
|
||||||
"sunrise": daily.get("sunrise", [""] * 3)[i].split("T")[-1] if daily.get("sunrise") else "",
|
"sunrise": sunrises[i].split("T")[-1] if i < len(sunrises) and sunrises[i] else "",
|
||||||
"sunset": daily.get("sunset", [""] * 3)[i].split("T")[-1] if daily.get("sunset") 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"])
|
logger.info("[WEATHER] Fetched '%s': %d°C, %s", geo["name"], result["temp"], result["description"])
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def fetch_hourly_forecast(location: str) -> List[Dict[str, Any]]:
|
async def fetch_hourly_forecast(location: str, max_slots: int = 8) -> List[Dict[str, Any]]:
|
||||||
"""Fetch hourly forecast for the next 8 hours from Open-Meteo.
|
"""Fetch hourly forecast from Open-Meteo.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
location: City name or coordinates.
|
location: City name or coordinates.
|
||||||
|
max_slots: Maximum number of hourly slots to return.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of hourly forecast dicts (max 8 entries).
|
List of hourly forecast dicts.
|
||||||
"""
|
"""
|
||||||
geo = await _geocode(location)
|
geo = await _geocode(location)
|
||||||
if geo is None:
|
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,
|
"wind_kmh": round(winds[i]) if i < len(winds) else 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
if len(upcoming) >= 8:
|
if len(upcoming) >= max_slots:
|
||||||
break
|
break
|
||||||
|
|
||||||
logger.debug("[WEATHER] Hourly for '%s': %d slots", location, len(upcoming))
|
logger.debug("[WEATHER] Hourly for '%s': %d slots", location, len(upcoming))
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,14 @@ export default function WeatherSettings() {
|
||||||
<TextInput
|
<TextInput
|
||||||
value={(config.secondary_location as string) || ""}
|
value={(config.secondary_location as string) || ""}
|
||||||
onChange={(v) => setConfig("secondary_location", v)}
|
onChange={(v) => setConfig("secondary_location", v)}
|
||||||
|
placeholder="z.B. Rab,Croatia oder 44.76,14.76"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Tertiärer Standort" description="Dritter Standort (z.B. München)">
|
||||||
|
<TextInput
|
||||||
|
value={(config.tertiary_location as string) || ""}
|
||||||
|
onChange={(v) => setConfig("tertiary_location", v)}
|
||||||
placeholder="z.B. München oder 48.137,11.576"
|
placeholder="z.B. München oder 48.137,11.576"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,10 @@ export interface HourlySlot {
|
||||||
export interface WeatherResponse {
|
export interface WeatherResponse {
|
||||||
primary: WeatherData;
|
primary: WeatherData;
|
||||||
secondary: WeatherData;
|
secondary: WeatherData;
|
||||||
|
tertiary: WeatherData;
|
||||||
hourly: HourlySlot[];
|
hourly: HourlySlot[];
|
||||||
|
hourly_secondary: HourlySlot[];
|
||||||
|
hourly_tertiary: HourlySlot[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NewsArticle {
|
export interface NewsArticle {
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,17 @@ import type { WeatherData } from "../api";
|
||||||
|
|
||||||
interface WeatherCardProps {
|
interface WeatherCardProps {
|
||||||
data: WeatherData;
|
data: WeatherData;
|
||||||
accent: "cyan" | "amber";
|
accent: "cyan" | "amber" | "iris";
|
||||||
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACCENT = {
|
const ACCENT = {
|
||||||
cyan: { strip: "gold", text: "text-gold", glow: "data-glow-gold", tag: "border-gold/30 text-gold bg-gold/5" },
|
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" },
|
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;
|
} as const;
|
||||||
|
|
||||||
export default function WeatherCard({ data, accent }: WeatherCardProps) {
|
export default function WeatherCard({ data, accent, onClick }: WeatherCardProps) {
|
||||||
const a = ACCENT[accent];
|
const a = ACCENT[accent];
|
||||||
|
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
|
|
@ -29,7 +31,14 @@ export default function WeatherCard({ data, accent }: WeatherCardProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="deck-card corner-marks p-6 animate-fade-in" data-accent={a.strip}>
|
<div
|
||||||
|
className="deck-card corner-marks p-6 animate-fade-in cursor-pointer hover:border-base-400 transition-colors"
|
||||||
|
data-accent={a.strip}
|
||||||
|
onClick={onClick}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && onClick?.()}
|
||||||
|
>
|
||||||
{/* Location tag */}
|
{/* Location tag */}
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<span className={`tag ${a.tag}`}>{data.location}</span>
|
<span className={`tag ${a.tag}`}>{data.location}</span>
|
||||||
|
|
|
||||||
223
web/src/components/WeatherDetailModal.tsx
Normal file
223
web/src/components/WeatherDetailModal.tsx
Normal file
|
|
@ -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<HTMLButtonElement>(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(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={`Wetter ${weather.location}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="deck-card w-full max-w-2xl max-h-[90vh] overflow-y-auto mx-4 animate-modal-in"
|
||||||
|
data-accent={a.strip}
|
||||||
|
style={{ scrollbarWidth: "thin" }}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between p-6 pb-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className={`text-lg font-bold uppercase tracking-wide ${a.text}`}>
|
||||||
|
{weather.location}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-base-600 mt-1 capitalize">{weather.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="flex items-baseline gap-0.5">
|
||||||
|
<span className={`text-4xl font-mono font-bold ${a.text} ${a.glow} tracking-tighter`}>
|
||||||
|
{Math.round(weather.temp)}
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-light text-base-500">°</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-4xl select-none opacity-80">{weather.icon}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
ref={closeRef}
|
||||||
|
onClick={onClose}
|
||||||
|
className="ml-4 p-1.5 text-base-500 hover:text-base-900 transition-colors"
|
||||||
|
aria-label="Schließen"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick stats */}
|
||||||
|
<div className="grid grid-cols-4 gap-px bg-base-300 mx-6 mb-6">
|
||||||
|
<MiniStat icon={<Wind className="w-3 h-3" />} label="Wind" value={`${Math.round(weather.wind_kmh)} km/h`} />
|
||||||
|
<MiniStat icon={<Droplets className="w-3 h-3" />} label="Feuchte" value={`${weather.humidity}%`} />
|
||||||
|
{today?.sunrise && (
|
||||||
|
<MiniStat icon={<Sunrise className="w-3 h-3" />} label="Aufgang" value={today.sunrise} />
|
||||||
|
)}
|
||||||
|
{today?.sunset && (
|
||||||
|
<MiniStat icon={<Sunset className="w-3 h-3" />} label="Untergang" value={today.sunset} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hourly forecast */}
|
||||||
|
{hourly.length > 0 && (
|
||||||
|
<div className="px-6 mb-6">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<span className="section-number">Stündlich</span>
|
||||||
|
<span className="section-rule" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex gap-0 overflow-x-auto border border-base-300"
|
||||||
|
style={{ scrollbarWidth: "none" }}
|
||||||
|
>
|
||||||
|
{hourly.map((slot, i) => (
|
||||||
|
<div
|
||||||
|
key={slot.time}
|
||||||
|
className={`
|
||||||
|
flex flex-col items-center gap-1 min-w-[3.5rem] px-2 py-3
|
||||||
|
border-r border-base-300 last:border-r-0
|
||||||
|
${i === 0 ? `${a.bg}/[0.06]` : "hover:bg-base-100"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span className={`text-[9px] font-mono font-medium tracking-wider uppercase ${
|
||||||
|
i === 0 ? a.text : "text-base-500"
|
||||||
|
}`}>
|
||||||
|
{i === 0 ? "Jetzt" : slot.time}
|
||||||
|
</span>
|
||||||
|
<span className="text-lg select-none">{slot.icon}</span>
|
||||||
|
<span className={`text-xs data-value ${i === 0 ? `${a.text} ${a.glow}` : "text-base-900"}`}>
|
||||||
|
{Math.round(slot.temp)}°
|
||||||
|
</span>
|
||||||
|
{slot.precip_chance > 0 && (
|
||||||
|
<span className="text-[8px] font-mono text-azure/80">
|
||||||
|
{slot.precip_chance}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Multi-day forecast */}
|
||||||
|
{forecast.length > 0 && (
|
||||||
|
<div className="px-6 pb-6">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<span className="section-number">7 Tage</span>
|
||||||
|
<span className="section-rule" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-base-300">
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={day.date}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-3 px-4 py-2.5
|
||||||
|
border-b border-base-300 last:border-b-0
|
||||||
|
${i === 0 ? `${a.bg}/[0.04]` : ""}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Day + date */}
|
||||||
|
<span className={`text-xs font-mono font-semibold w-6 ${i === 0 ? a.text : "text-base-700"}`}>
|
||||||
|
{i === 0 ? "Heu" : dayName}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] font-mono text-base-500 w-12">{dateStr}</span>
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<span className="text-base select-none w-6 text-center">{day.icon}</span>
|
||||||
|
|
||||||
|
{/* Min temp */}
|
||||||
|
<span className="text-xs font-mono text-base-500 w-7 text-right">
|
||||||
|
{day.min_temp}°
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Temperature range bar */}
|
||||||
|
<div className="flex-1 h-1.5 bg-base-200 relative mx-1">
|
||||||
|
<div
|
||||||
|
className={`absolute top-0 h-full ${a.bar} transition-all duration-500`}
|
||||||
|
style={{
|
||||||
|
left: `${barLeft}%`,
|
||||||
|
width: `${Math.max(barWidth, 3)}%`,
|
||||||
|
borderRadius: "1px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Max temp */}
|
||||||
|
<span className="text-xs font-mono text-base-900 w-7">
|
||||||
|
{day.max_temp}°
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniStat({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-1 py-2.5 bg-base-50">
|
||||||
|
<span className="text-base-500">{icon}</span>
|
||||||
|
<span className="text-xs data-value text-base-900">{value}</span>
|
||||||
|
<span className="data-label">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,20 @@ export const MOCK_DATA: DashboardData = {
|
||||||
{ date: "2026-03-04", max_temp: 17, min_temp: 9, icon: "⛅", description: "Partly cloudy" },
|
{ 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: [
|
hourly: [
|
||||||
{ time: "14:00", temp: 8, icon: "⛅", precip_chance: 10 },
|
{ time: "14:00", temp: 8, icon: "⛅", precip_chance: 10 },
|
||||||
{ time: "15:00", temp: 9, icon: "🌤️", precip_chance: 5 },
|
{ 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: "20:00", temp: 4, icon: "🌧️", precip_chance: 60 },
|
||||||
{ time: "21:00", temp: 4, icon: "☁️", precip_chance: 35 },
|
{ 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: {
|
news: {
|
||||||
articles: [
|
articles: [
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
|
import { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useDashboard } from "../hooks/useDashboard";
|
import { useDashboard } from "../hooks/useDashboard";
|
||||||
import Clock from "../components/Clock";
|
import Clock from "../components/Clock";
|
||||||
import WeatherCard from "../components/WeatherCard";
|
import WeatherCard from "../components/WeatherCard";
|
||||||
import HourlyForecast from "../components/HourlyForecast";
|
import WeatherDetailModal from "../components/WeatherDetailModal";
|
||||||
import NewsGrid from "../components/NewsGrid";
|
import NewsGrid from "../components/NewsGrid";
|
||||||
import ServerCard from "../components/ServerCard";
|
import ServerCard from "../components/ServerCard";
|
||||||
import HomeAssistant from "../components/HomeAssistant";
|
import HomeAssistant from "../components/HomeAssistant";
|
||||||
|
|
@ -10,8 +11,29 @@ import TasksCard from "../components/TasksCard";
|
||||||
import MqttCard from "../components/MqttCard";
|
import MqttCard from "../components/MqttCard";
|
||||||
import { RefreshCw, AlertTriangle, Settings } from "lucide-react";
|
import { RefreshCw, AlertTriangle, Settings } from "lucide-react";
|
||||||
|
|
||||||
|
type WeatherLocationKey = "primary" | "secondary" | "tertiary";
|
||||||
|
|
||||||
|
const WEATHER_ACCENTS: Record<WeatherLocationKey, "cyan" | "amber" | "iris"> = {
|
||||||
|
primary: "cyan",
|
||||||
|
secondary: "amber",
|
||||||
|
tertiary: "iris",
|
||||||
|
};
|
||||||
|
|
||||||
|
const WEATHER_MODAL_COLORS: Record<WeatherLocationKey, "gold" | "mint" | "iris"> = {
|
||||||
|
primary: "gold",
|
||||||
|
secondary: "mint",
|
||||||
|
tertiary: "iris",
|
||||||
|
};
|
||||||
|
|
||||||
|
const HOURLY_KEYS: Record<WeatherLocationKey, "hourly" | "hourly_secondary" | "hourly_tertiary"> = {
|
||||||
|
primary: "hourly",
|
||||||
|
secondary: "hourly_secondary",
|
||||||
|
tertiary: "hourly_tertiary",
|
||||||
|
};
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const { data, loading, error, refresh } = useDashboard();
|
const { data, loading, error, refresh } = useDashboard();
|
||||||
|
const [weatherModal, setWeatherModal] = useState<WeatherLocationKey | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen text-base-800">
|
<div className="min-h-screen text-base-800">
|
||||||
|
|
@ -74,13 +96,26 @@ export default function Dashboard() {
|
||||||
<span className="section-rule" />
|
<span className="section-rule" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-px bg-base-300">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-px bg-base-300">
|
||||||
<WeatherCard data={data.weather.primary} accent="cyan" />
|
{(["primary", "secondary", "tertiary"] as const).map((key) => (
|
||||||
<WeatherCard data={data.weather.secondary} accent="amber" />
|
<WeatherCard
|
||||||
<div className="md:col-span-2">
|
key={key}
|
||||||
<HourlyForecast slots={data.weather.hourly} />
|
data={data.weather[key]}
|
||||||
</div>
|
accent={WEATHER_ACCENTS[key]}
|
||||||
|
onClick={() => setWeatherModal(key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Weather detail modal */}
|
||||||
|
{weatherModal && (
|
||||||
|
<WeatherDetailModal
|
||||||
|
weather={data.weather[weatherModal]}
|
||||||
|
hourly={data.weather[HOURLY_KEYS[weatherModal]] || []}
|
||||||
|
accentColor={WEATHER_MODAL_COLORS[weatherModal]}
|
||||||
|
onClose={() => setWeatherModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Section 02 — Infrastruktur */}
|
{/* Section 02 — Infrastruktur */}
|
||||||
|
|
@ -157,8 +192,8 @@ function LoadingSkeleton() {
|
||||||
<div className="h-3 w-16 bg-base-200" />
|
<div className="h-3 w-16 bg-base-200" />
|
||||||
<span className="section-rule" />
|
<span className="section-rule" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-px bg-base-300">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-px bg-base-300">
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
<SkeletonCard key={`w-${i}`} className="h-52" />
|
<SkeletonCard key={`w-${i}`} className="h-52" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,7 @@
|
||||||
.data-glow-gold { text-shadow: 0 0 20px #e8a44a33, 0 0 40px #e8a44a15; }
|
.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-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-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 ─────────────────────────────────────────── */
|
/* ── Tags/Badges ─────────────────────────────────────────── */
|
||||||
.tag {
|
.tag {
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ export default {
|
||||||
"slide-in": "slideIn 0.3s ease-out both",
|
"slide-in": "slideIn 0.3s ease-out both",
|
||||||
"scan": "scan 1.5s ease-in-out infinite",
|
"scan": "scan 1.5s ease-in-out infinite",
|
||||||
"glow-pulse": "glowPulse 2s ease-in-out infinite",
|
"glow-pulse": "glowPulse 2s ease-in-out infinite",
|
||||||
|
"modal-in": "modalIn 0.25s ease-out both",
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
fadeIn: {
|
fadeIn: {
|
||||||
|
|
@ -72,6 +73,10 @@ export default {
|
||||||
"0%, 100%": { opacity: "0.5" },
|
"0%, 100%": { opacity: "0.5" },
|
||||||
"50%": { opacity: "1" },
|
"50%": { opacity: "1" },
|
||||||
},
|
},
|
||||||
|
modalIn: {
|
||||||
|
"0%": { opacity: "0", transform: "scale(0.95) translateY(8px)" },
|
||||||
|
"100%": { opacity: "1", transform: "scale(1) translateY(0)" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue