diff --git a/src/lib/components/blocks/StrommixBlock.svelte b/src/lib/components/blocks/StrommixBlock.svelte index 92812a0..762ee23 100644 --- a/src/lib/components/blocks/StrommixBlock.svelte +++ b/src/lib/components/blocks/StrommixBlock.svelte @@ -4,6 +4,7 @@ import { getBlockLayoutClasses } from "$lib/block-layout"; import type { LiveStrommixBlockData } from "$lib/block-types"; import type { StrommixResponse } from "$lib/strommix"; + import { STROMMIX_DEFAULT_THRESHOLDS } from "$lib/strommix"; import { useTranslate, T } from "$lib/translations"; import Icon from "@iconify/svelte"; import "$lib/iconify-offline"; @@ -12,20 +13,30 @@ import DunkelflauteCounter from "./strommix/DunkelflauteCounter.svelte"; import NegPriceCounter from "./strommix/NegPriceCounter.svelte"; import FlowBalanceTodayYtd from "./strommix/FlowBalanceTodayYtd.svelte"; + import RenewableSparkline from "./strommix/RenewableSparkline.svelte"; let { block }: { block: LiveStrommixBlockData } = $props(); const t = useTranslate(); const layoutClasses = $derived(getBlockLayoutClasses(block.layout)); const isCompact = $derived(block.variant === "compact"); + const isHero = $derived(block.variant === "hero"); + /** Variants without sub-cards / accordion / details. Hero & compact + * keep things minimal; full renders the entire dashboard. */ + const isMinimal = $derived(isCompact || isHero); // Live data + lifecycle let live = $state(null); let loadError = $state(null); + let isLoading = $state(false); let lastFetchAt = $state(null); let pollTimer: ReturnType | null = null; - async function loadLive() { + /** Fetch live snapshot. `silent` keeps the existing data on screen + * during a background re-poll; only the first load and explicit + * retries flip `isLoading` so the skeleton appears. */ + async function loadLive(silent = false) { + if (!silent) isLoading = true; try { const res = await fetch("/api/strommix", { cache: "no-store" }); if (!res.ok) throw new Error(`HTTP ${res.status}`); @@ -34,35 +45,115 @@ loadError = null; } catch (e) { loadError = e instanceof Error ? e.message : String(e); + } finally { + if (!silent) isLoading = false; } } + /** Re-poll every 5 minutes while the document is visible. Pause on + * hidden tab so embedded/idle widgets don't burn API quota. On show + * trigger an immediate silent refresh if the last fetch is stale + * (>5 min old) so the visitor sees current data, not whatever was + * on screen when they switched away. */ + function startPolling() { + if (pollTimer) return; + pollTimer = setInterval(() => loadLive(true), 5 * 60 * 1000); + } + function stopPolling() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + } + function handleVisibility() { + if (typeof document === "undefined") return; + if (document.hidden) { + stopPolling(); + return; + } + startPolling(); + const stale = !lastFetchAt || Date.now() - lastFetchAt > 5 * 60 * 1000; + if (stale) loadLive(true); + } + onMount(() => { loadLive(); - pollTimer = setInterval(loadLive, 5 * 60 * 1000); // re-poll every 5 min + startPolling(); + if (typeof document !== "undefined") { + document.addEventListener("visibilitychange", handleVisibility); + } }); onDestroy(() => { - if (pollTimer) clearInterval(pollTimer); + stopPolling(); + if (typeof document !== "undefined") { + document.removeEventListener("visibilitychange", handleVisibility); + } }); + /** Current Last in MW. Falls back to the most recent history slot + * if the live snapshot lost its load value (SMARD load filter + * occasionally lags behind production). Keeps the % live instead of + * showing the red "Lastdaten fehlen" banner for the brief lag + * window. */ + const effectiveLoadMw = $derived.by(() => { + if (!live) return 0; + if (live.loadMw > 0) return live.loadMw; + const history = live.todayHourly; + if (!history || history.length === 0) return 0; + for (let i = history.length - 1; i >= 0; i--) { + if (history[i].loadMw > 0) return history[i].loadMw; + } + return 0; + }); + const usingLoadFallback = $derived( + !!live && live.loadMw <= 0 && effectiveLoadMw > 0, + ); + // Derived numbers const renewablePct = $derived.by(() => { - if (!live || live.loadMw <= 0) return null; - return (live.weatherRenewableMw / live.loadMw) * 100; + if (!live || effectiveLoadMw <= 0) return null; + return (live.weatherRenewableMw / effectiveLoadMw) * 100; }); type Bucket = "dunkelflaute" | "niedrig" | "mittel" | "hoch"; const bucket = $derived.by((): Bucket | null => { if (renewablePct == null) return null; - const t1 = block.threshold_dunkelflaute ?? 15; - const t2 = block.threshold_niedrig ?? 35; - const t3 = block.threshold_mittel ?? 60; + const t1 = block.threshold_dunkelflaute ?? STROMMIX_DEFAULT_THRESHOLDS.dunkelflaute; + const t2 = block.threshold_niedrig ?? STROMMIX_DEFAULT_THRESHOLDS.niedrig; + const t3 = block.threshold_mittel ?? STROMMIX_DEFAULT_THRESHOLDS.mittel; if (renewablePct < t1) return "dunkelflaute"; if (renewablePct < t2) return "niedrig"; if (renewablePct < t3) return "mittel"; return "hoch"; }); + /** Compare current % to the slot one hour back in `todayHourly` to + * give a quick "is it climbing or dropping?" cue alongside the + * status label. Threshold ≥0.5 %-pt to filter flat noise. */ + type Trend = { direction: "up" | "down" | "flat"; deltaPct: number }; + const trend = $derived.by((): Trend | null => { + if (renewablePct == null || !live?.todayHourly) return null; + const slots = live.todayHourly; + if (slots.length < 2) return null; + const nowUnix = live.measuredAtUnix ?? slots[slots.length - 1].unix; + const target = nowUnix - 3600; + let prev: typeof slots[number] | null = null; + for (let i = slots.length - 1; i >= 0; i--) { + if (slots[i].unix <= target && slots[i].loadMw > 0) { + prev = slots[i]; + break; + } + } + if (!prev) return null; + const prevPct = (prev.weatherRenewableMw / prev.loadMw) * 100; + const delta = renewablePct - prevPct; + if (Math.abs(delta) < 0.5) return { direction: "flat", deltaPct: 0 }; + return { + direction: delta > 0 ? "up" : "down", + deltaPct: Math.abs(delta), + }; + }); + const statusLabel = $derived.by(() => { if (!bucket) return ""; const labels: Record = { @@ -145,6 +236,22 @@ minute: "2-digit", }); } + function fmtDateTimeAttr(unix: number | null): string { + if (!unix) return ""; + return new Date(unix * 1000).toISOString(); + } + function fmtDateTimeFull(unix: number | null): string { + if (!unix) return ""; + const d = new Date(unix * 1000); + return d.toLocaleString("de-DE", { + weekday: "short", + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } /** Cross-border data is considered stale (and worth flagging) when its * timestamp is more than 30 min behind the production-snapshot. cbpf @@ -230,10 +337,29 @@ if (!live) return null; return live.netFlowGw >= 0 ? "Export" : "Import"; }); + + /** History-Panels (Residuallast, Dunkelflaute, NegPreis, Flow, + * Capacity-Factor) sind sekundärer Kontext. Im Akkordeon gebündelt + * damit die Hauptkennzahlen oben dominieren — aufklappbar wenn + * Leser mehr Tiefe will. */ + const hasHistoryPanels = $derived.by(() => { + if (!live || isMinimal) return false; + return !!( + (block.installed_wind_onshore_gw || block.installed_solar_gw) || + (live.todayHourly && live.todayHourly.length > 0) || + live.lastDunkelflaute || + (live.worstDunkelflauten && live.worstDunkelflauten.length > 0) || + live.negPriceHoursYtd != null || + typeof block.einsman_costs_ytd_eur_mio === "number" || + live.crossborderTodayGwh || + live.netCrossborderGwhYtd != null + ); + });
@@ -303,7 +429,7 @@ Source-Badge zeigt klar woher die zahlen kommen ohne dass man die Detail-Aufklappkachel öffnen muss. -->
{#if live} - - - {#if live.measuredAtUnix} - - {t(T.strommix_stand, { time: fmtTime(live.measuredAtUnix) })} + {#if isStale} + + + {:else} + + + {/if} + {#if live.measuredAtUnix} + {/if} {/if}
- {#if loadError} -
- {t(T.strommix_load_error, { error: loadError })} + {#if loadError && !live} +
+
{:else if !live} -
- {t(T.strommix_loading)} + +
+
+
+
+ {t(T.strommix_loading)}
{:else} -
+
{#if renewablePct != null} {#key live.measuredAtUnix}
{Math.round(renewablePct)}%
{/key} -
+
{t(T.strommix_wind_solar_share)}
→ {statusLabel}
- {#if argumentHtml} + {#if trend} +
+ {#if trend.direction === "up"} + {t(T.strommix_trend_up, { delta: trend.deltaPct.toFixed(1).replace(".", ",") })} + {:else if trend.direction === "down"} + {t(T.strommix_trend_down, { delta: trend.deltaPct.toFixed(1).replace(".", ",") })} + {:else} + {t(T.strommix_trend_flat)} + {/if} +
+ {/if} + {#if live.todayHourly && live.todayHourly.length >= 2} +
+ +
+ {/if} + {#if argumentHtml && !isHero}
{@html argumentHtml}
{/if} + {#if usingLoadFallback} +
+ {t(T.strommix_load_missing)} +
+ {/if} {:else} @@ -386,21 +560,23 @@ - {#if live} -
-
+ karte mit %-zahl optisch dominiert. Hero-Variante zeigt nur + Hauptzahl + Sparkline; sub-cards würden den minimalen Charakter + brechen. --> + {#if live && !isHero} +
+
{t(T.strommix_conventional_now)}
-
+
{fmtMw(live.conventionalMw)}
-
+
{t(T.strommix_conventional_formula)}
-
+
{importDirection === "Import" ? t(T.strommix_import) @@ -408,16 +584,21 @@ ? t(T.strommix_export) : t(T.strommix_saldo)}
-
+
{#if importDirection === "Import"}↓{/if} {#if importDirection === "Export"}↑{/if} {fmtGw(live.netFlowGw)}
{#if live.crossborderMeasuredAtUnix} -
- {t(T.strommix_stand, { - time: fmtTime(live.crossborderMeasuredAtUnix), - })} +
+ {#if cbStale} · {t(T.strommix_delayed)}{/if} @@ -426,41 +607,36 @@
- -
-
- - {t(T.strommix_price_de, { price: fmtPrice(live.priceDeEurMwh) })} - - {#if enableFr && live.priceFrEurMwh != null} - - {t(T.strommix_price_fr_inline, { price: fmtPrice(live.priceFrEurMwh) })} - - {/if} - {#if live.priceMeasuredAtUnix} - - · {t(T.strommix_slot, { time: fmtTime(live.priceMeasuredAtUnix) })} - - {/if} -
-
- - {#if (live.priceDeEurMwh ?? 0) < 0} -
+ +