feat(strommix): hero variant, 24h sparkline, trend, polishing

- New "hero" variant between compact and full: pct + status +
  sparkline + trend, no sub-cards or accordion
- 24h Wind+Solar sparkline below hero pct, accent-colored
- Trend arrow vs previous hour with delta percentage points
- Polling pauses on tab hidden, refetches on visibility return
  if last fetch >5min old
- Skeleton loader replaces "Lade…" text (matches final layout)
- Retry button on error state (silent vs explicit fetch)
- Stale-flag now prominent red badge in header bar
- Stand-time wrapped in <time datetime title> for full-date hover
- loadMw=0 falls back to last hourly slot instead of red error
- Negative-price banner replaces standalone price card (dedup)
- Production breakdown grid: clean auto/1fr label-value alignment
- Print CSS: details auto-open, chevrons hidden
- All German strings moved to translation keys
- STROMMIX_DEFAULT_THRESHOLDS const as single source for buckets

Includes data-block="LiveStrommix" attribute for the new
identifier scheme + new RenewableSparkline.svelte component.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-05-07 15:44:57 +02:00
parent 5a86ff701e
commit 4790d571fd
4 changed files with 507 additions and 141 deletions
+373 -141
View File
@@ -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<StrommixResponse | null>(null);
let loadError = $state<string | null>(null);
let isLoading = $state(false);
let lastFetchAt = $state<number | null>(null);
let pollTimer: ReturnType<typeof setInterval> | 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<Bucket, string | undefined> = {
@@ -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
);
});
</script>
<div
class="live-strommix {layoutClasses}"
data-block="LiveStrommix"
data-block-type="live_strommix"
data-block-slug={block._slug}
>
@@ -303,7 +429,7 @@
Source-Badge zeigt klar woher die zahlen kommen ohne dass
man die Detail-Aufklappkachel öffnen muss. -->
<div
class="flex flex-wrap items-center gap-x-3 gap-y-1 justify-between px-4 py-2 border-b border-current/10 text-xs font-semibold {accentClasses.label}"
class="flex flex-wrap items-center gap-x-3 gap-y-1 justify-between px-4 py-1.5 border-b border-current/10 text-xs font-semibold {accentClasses.label}"
>
<span class="inline-flex items-center gap-1.5 uppercase tracking-wide">
<Icon
@@ -315,36 +441,63 @@
</span>
{#if live}
<span class="inline-flex items-center gap-2 normal-case font-normal">
<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] uppercase tracking-wide {accentClasses.badge}">
<Icon icon="mdi:database-outline" class="size-3" aria-hidden="true" />
{#if live.dataSource === "smard"}
SMARD
{:else if live.dataSource === "smard+energy-charts"}
SMARD + EC
{:else}
Energy-Charts
{/if}
</span>
{#if live.measuredAtUnix}
<span>
{t(T.strommix_stand, { time: fmtTime(live.measuredAtUnix) })}
{#if isStale}
<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] uppercase tracking-wide bg-fire-700 text-fire-50">
<Icon icon="mdi:cloud-off-outline" class="size-3" aria-hidden="true" />
{t(T.strommix_stale_badge)}
</span>
{:else}
<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] uppercase tracking-wide {accentClasses.badge}">
<Icon icon="mdi:database-outline" class="size-3" aria-hidden="true" />
{#if live.dataSource === "smard"}
SMARD
{:else if live.dataSource === "smard+energy-charts"}
SMARD + EC
{:else}
Energy-Charts
{/if}
</span>
{/if}
{#if live.measuredAtUnix}
<time
datetime={fmtDateTimeAttr(live.measuredAtUnix)}
title={fmtDateTimeFull(live.measuredAtUnix)}
>
{t(T.strommix_stand, { time: fmtTime(live.measuredAtUnix) })}
</time>
{/if}
</span>
{/if}
</div>
<!-- Main: percentage + status -->
{#if loadError}
<div class="p-6 text-sm text-fire-700">
{t(T.strommix_load_error, { error: loadError })}
{#if loadError && !live}
<div class="p-6 text-sm text-fire-700 flex flex-wrap items-center gap-3">
<Icon icon="mdi:alert-circle-outline" class="size-5 shrink-0" aria-hidden="true" />
<span class="flex-1 min-w-0">
{t(T.strommix_load_error, { error: loadError })}
</span>
<button
type="button"
class="inline-flex items-center gap-1 rounded-xs border border-fire-300 bg-fire-100 px-3 py-1 text-xs font-semibold text-fire-800 hover:bg-fire-200 disabled:opacity-50"
onclick={() => loadLive()}
disabled={isLoading}
>
<Icon icon="mdi:refresh" class="size-3.5 {isLoading ? 'animate-spin' : ''}" aria-hidden="true" />
{t(T.strommix_retry)}
</button>
</div>
{:else if !live}
<div class="p-6 text-center text-stein-500 text-sm">
{t(T.strommix_loading)}
<!-- Skeleton: matches the final layout's footprint so the
% drop-in doesn't shift the rest of the page. -->
<div class="p-4 sm:p-6 text-center" aria-busy="true" aria-live="polite">
<div class="strommix-skel mx-auto h-16 sm:h-20 w-44 sm:w-56 rounded-xs bg-stein-200/70"></div>
<div class="strommix-skel mx-auto mt-3 h-3 w-40 rounded-xs bg-stein-200/60"></div>
<div class="strommix-skel mx-auto mt-2 h-4 w-32 rounded-xs bg-stein-200/60"></div>
<span class="sr-only">{t(T.strommix_loading)}</span>
</div>
{:else}
<div class="p-6 sm:p-8 text-center">
<div class="p-4 sm:p-6 text-center">
{#if renewablePct != null}
<!-- Key change keys the % display so a fresh fetch
triggers a brief CSS fade-in transition. The
@@ -352,26 +505,47 @@
supports it. -->
{#key live.measuredAtUnix}
<div
class="strommix-pct text-7xl sm:text-8xl font-bold tabular-nums leading-none {accentClasses.pct}"
class="strommix-pct text-5xl sm:text-7xl font-bold tabular-nums leading-none {accentClasses.pct}"
>
{Math.round(renewablePct)}%
</div>
{/key}
<div class="mt-2 text-sm sm:text-base text-stein-700">
<div class="mt-1.5 text-sm text-stein-700">
{t(T.strommix_wind_solar_share)}
</div>
<div
class="mt-3 text-lg font-semibold {accentClasses.label}"
class="mt-2 text-base font-semibold {accentClasses.label}"
>
{statusLabel}
</div>
{#if argumentHtml}
{#if trend}
<div class="mt-1 text-[11px] text-stein-500 tabular-nums">
{#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}
</div>
{/if}
{#if live.todayHourly && live.todayHourly.length >= 2}
<div class="mt-2 {accentClasses.label}">
<RenewableSparkline points={live.todayHourly} accent="currentColor" />
</div>
{/if}
{#if argumentHtml && !isHero}
<div
class="prose prose-xs mt-2 max-w-md mx-auto text-xs leading-snug text-stein-700 [&_p]:my-1"
>
{@html argumentHtml}
</div>
{/if}
{#if usingLoadFallback}
<div class="mt-2 text-[11px] text-stein-500 italic">
{t(T.strommix_load_missing)}
</div>
{/if}
{:else}
<!-- Production data arrived but load is missing → no percentage,
but we still show what we have so the widget isn't blank. -->
@@ -386,21 +560,23 @@
<!-- Context cards: konventionell + saldo, je eigenes box-block.
Stack mobile, side-by-side ab sm. Kein farbakzent damit haupt-
karte mit %-zahl optisch dominiert. -->
{#if live}
<div class="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="rounded-xs border border-stein-200 bg-white px-4 py-3 text-sm shadow-sm">
karte mit %-zahl optisch dominiert. Hero-Variante zeigt nur
Hauptzahl + Sparkline; sub-cards würden den minimalen Charakter
brechen. -->
{#if live && !isHero}
<div class="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2">
<div class="rounded-xs border border-stein-200 bg-white px-4 py-2.5 text-sm shadow-sm">
<div class="text-xs text-stein-600 uppercase tracking-wide">
{t(T.strommix_conventional_now)}
</div>
<div class="text-2xl font-semibold tabular-nums text-stein-900">
<div class="text-xl font-semibold tabular-nums text-stein-900">
{fmtMw(live.conventionalMw)}
</div>
<div class="text-[11px] text-stein-500 mt-1">
<div class="text-[11px] text-stein-500 mt-0.5">
{t(T.strommix_conventional_formula)}
</div>
</div>
<div class="rounded-xs border border-stein-200 bg-white px-4 py-3 text-sm shadow-sm">
<div class="rounded-xs border border-stein-200 bg-white px-4 py-2.5 text-sm shadow-sm">
<div class="text-xs text-stein-600 uppercase tracking-wide">
{importDirection === "Import"
? t(T.strommix_import)
@@ -408,16 +584,21 @@
? t(T.strommix_export)
: t(T.strommix_saldo)}
</div>
<div class="text-2xl font-semibold tabular-nums text-stein-900">
<div class="text-xl font-semibold tabular-nums text-stein-900">
{#if importDirection === "Import"}{/if}
{#if importDirection === "Export"}{/if}
{fmtGw(live.netFlowGw)}
</div>
{#if live.crossborderMeasuredAtUnix}
<div class="text-[11px] text-stein-500 mt-1">
{t(T.strommix_stand, {
time: fmtTime(live.crossborderMeasuredAtUnix),
})}
<div class="text-[11px] text-stein-500 mt-0.5">
<time
datetime={fmtDateTimeAttr(live.crossborderMeasuredAtUnix)}
title={fmtDateTimeFull(live.crossborderMeasuredAtUnix)}
>
{t(T.strommix_stand, {
time: fmtTime(live.crossborderMeasuredAtUnix),
})}
</time>
{#if cbStale}<span class="text-fire-700">
· {t(T.strommix_delayed)}</span
>{/if}
@@ -426,41 +607,36 @@
</div>
</div>
<!-- Price card — separater block, full-width damit der vergleich
DE/FR + slot-info nicht in den 2-spaltigen flow oben gepresst
werden muss. -->
<div class="mt-3 rounded-xs border border-stein-200 bg-white px-4 py-3 text-sm shadow-sm">
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
<span class="font-semibold">
{t(T.strommix_price_de, { price: fmtPrice(live.priceDeEurMwh) })}
</span>
{#if enableFr && live.priceFrEurMwh != null}
<span class="text-stein-600">
{t(T.strommix_price_fr_inline, { price: fmtPrice(live.priceFrEurMwh) })}
</span>
{/if}
{#if live.priceMeasuredAtUnix}
<span class="text-[11px] text-stein-500">
· {t(T.strommix_slot, { time: fmtTime(live.priceMeasuredAtUnix) })}
</span>
{/if}
</div>
</div>
<!-- Negativpreis-warnung als eigener prominenter banner statt
inline-badge. Greift wenn Day-Ahead-Preis negativ — sehr
aussagekräftiger marktzustand der nicht versteckt werden
sollte. -->
{#if (live.priceDeEurMwh ?? 0) < 0}
<div class="mt-3 flex items-start gap-3 rounded-xs border-2 border-fire-500 bg-fire-50 px-4 py-3 text-sm shadow-sm">
<!-- Negativpreis-warnung als eigener prominenter banner statt
inline-badge. Greift wenn Day-Ahead-Preis negativ — sehr
aussagekräftiger marktzustand der nicht versteckt werden
sollte. Ersetzt die separate Price-Card komplett, weil
derselbe Preis sonst zweimal genannt würde. -->
<div class="mt-2 flex items-start gap-3 rounded-xs border-2 border-fire-500 bg-fire-50 px-4 py-2.5 text-sm shadow-sm">
<Icon
icon="mdi:alert-circle"
class="size-6 shrink-0 text-fire-700"
aria-hidden="true"
/>
<div class="min-w-0">
<div class="text-base font-bold text-fire-800">
{t(T.strommix_negative_price)}: {fmtPrice(live.priceDeEurMwh)}
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-0.5">
<span class="text-base font-bold text-fire-800">
{t(T.strommix_negative_price)}: {fmtPrice(live.priceDeEurMwh)}
</span>
{#if enableFr && live.priceFrEurMwh != null}
<span class="text-xs text-fire-900/70">
{t(T.strommix_price_fr_inline, { price: fmtPrice(live.priceFrEurMwh) })}
</span>
{/if}
{#if live.priceMeasuredAtUnix}
<span class="text-[11px] text-fire-900/60">
· <time
datetime={fmtDateTimeAttr(live.priceMeasuredAtUnix)}
title={fmtDateTimeFull(live.priceMeasuredAtUnix)}
>{t(T.strommix_slot, { time: fmtTime(live.priceMeasuredAtUnix) })}</time>
</span>
{/if}
</div>
{#if showDisclaimer}
<div class="mt-1 text-xs leading-snug text-fire-900/80 prose prose-xs max-w-none [&_p]:my-0">
@@ -469,91 +645,121 @@
{/if}
</div>
</div>
{:else}
<!-- Price card — separater block, full-width damit DE/FR-
Vergleich + Slot-Info sauber nebeneinander stehen. Bei
Negativpreisen unterdrückt; der Negativbanner oben zeigt
dann denselben Preis prominenter. -->
<div class="mt-2 rounded-xs border border-stein-200 bg-white px-4 py-2 text-sm shadow-sm">
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
<span class="font-semibold">
{t(T.strommix_price_de, { price: fmtPrice(live.priceDeEurMwh) })}
</span>
{#if enableFr && live.priceFrEurMwh != null}
<span class="text-stein-600">
{t(T.strommix_price_fr_inline, { price: fmtPrice(live.priceFrEurMwh) })}
</span>
{/if}
{#if live.priceMeasuredAtUnix}
<span class="text-[11px] text-stein-500">
· <time
datetime={fmtDateTimeAttr(live.priceMeasuredAtUnix)}
title={fmtDateTimeFull(live.priceMeasuredAtUnix)}
>{t(T.strommix_slot, { time: fmtTime(live.priceMeasuredAtUnix) })}</time>
</span>
{/if}
</div>
</div>
{/if}
{/if}
<!-- Capacity-factor bars: live vs. installiert. Nur full-Variante; in
compact wäre's optisch zu dicht. Renders nur, wenn Stammdaten
gesetzt sind. -->
{#if live && !isCompact && (block.installed_wind_onshore_gw || block.installed_solar_gw)}
<div class="mt-4 rounded-xs border border-stein-200 bg-white px-4 py-3 shadow-sm">
<CapacityFactorBars
windOnshoreLiveMw={live.windOnshoreMw}
windOffshoreLiveMw={live.windOffshoreMw}
solarLiveMw={live.solarMw}
installedWindOnshoreGw={block.installed_wind_onshore_gw ?? 0}
installedWindOffshoreGw={block.installed_wind_offshore_gw ?? 0}
installedSolarGw={block.installed_solar_gw ?? 0}
installedAsOf={block.installed_as_of}
/>
</div>
{/if}
<!-- History-Panels gebündelt im Akkordeon: Residuallast,
Dunkelflaute, Negativpreise, Cross-Border, Capacity-Factor.
Default zu — Hauptkennzahlen oben sollen dominieren. -->
{#if live && hasHistoryPanels}
<details class="strommix-history mt-3 group">
<summary class="cursor-pointer rounded-xs border border-stein-200 bg-white px-4 py-2 text-sm font-semibold text-stein-700 shadow-sm hover:bg-stein-50 inline-flex items-center gap-2">
<Icon icon="mdi:chevron-right" class="size-4 transition-transform group-open:rotate-90" aria-hidden="true" />
{t(T.strommix_history_summary)}
</summary>
<!-- Residuallast-Kurve heute: Last minus Wind+Solar. Nur full-Variante,
und nur wenn der Aggregator Historien-Daten geliefert hat (CMS mit
history-mode-Schema im Hintergrund — sonst null). -->
{#if live && !isCompact && live.todayHourly && live.todayHourly.length > 0}
<div class="mt-4 rounded-xs border border-stein-200 bg-white px-4 py-3 shadow-sm">
<h4 class="text-sm font-semibold text-stein-800">
Residuallast heute
</h4>
<p class="mt-0.5 text-[11px] text-stein-600">
Last minus Wind+Solar im Tagesverlauf. Die schraffierte Lücke
füllen konventionelle Kraftwerke und Importe.
</p>
<div class="mt-2">
<ResidualLoadChart points={live.todayHourly} />
</div>
</div>
{/if}
{#if live.todayHourly && live.todayHourly.length > 0}
<div class="mt-2 rounded-xs border border-stein-200 bg-white px-4 py-2.5 shadow-sm">
<h4 class="text-sm font-semibold text-stein-800">
{t(T.strommix_residual_today_title)}
</h4>
<p class="mt-0.5 text-[11px] text-stein-600">
{t(T.strommix_residual_today_desc)}
</p>
<div class="mt-2">
<ResidualLoadChart points={live.todayHourly} />
</div>
</div>
{/if}
<!-- Dunkelflaute-Counter: letzte Phase + Top-5 Liste. Nur full-Variante.
Renders nur, wenn Aggregator History-Daten + qualifying runs hat. -->
{#if live && !isCompact && (live.lastDunkelflaute || (live.worstDunkelflauten && live.worstDunkelflauten.length > 0))}
<div class="mt-4 rounded-xs border border-stein-200 bg-white px-4 py-3 shadow-sm">
<DunkelflauteCounter
last={live.lastDunkelflaute}
worst={live.worstDunkelflauten}
/>
</div>
{/if}
{#if live.lastDunkelflaute || (live.worstDunkelflauten && live.worstDunkelflauten.length > 0)}
<div class="mt-2 rounded-xs border border-stein-200 bg-white px-4 py-2.5 shadow-sm">
<DunkelflauteCounter
last={live.lastDunkelflaute}
worst={live.worstDunkelflauten}
/>
</div>
{/if}
<!-- Negativpreis- + EinsMan-Counter. Nur full. Renders wenn entweder
History-Aggregat oder hand-gepflegte EinsMan-Zahl vorhanden. -->
{#if live && !isCompact && (live.negPriceHoursYtd != null || typeof block.einsman_costs_ytd_eur_mio === "number")}
<div class="mt-4 rounded-xs border border-stein-200 bg-white px-4 py-3 shadow-sm">
<NegPriceCounter
negHoursYtd={live.negPriceHoursYtd}
einsmanEurMio={block.einsman_costs_ytd_eur_mio ?? null}
einsmanAsOf={block.einsman_costs_as_of}
/>
</div>
{/if}
{#if live.negPriceHoursYtd != null || typeof block.einsman_costs_ytd_eur_mio === "number"}
<div class="mt-2 rounded-xs border border-stein-200 bg-white px-4 py-2.5 shadow-sm">
<NegPriceCounter
negHoursYtd={live.negPriceHoursYtd}
einsmanEurMio={block.einsman_costs_ytd_eur_mio ?? null}
einsmanAsOf={block.einsman_costs_as_of}
/>
</div>
{/if}
<!-- Cross-Border-Bilanz: heute + YTD pro Land. Nur full, nur wenn
History-Daten da sind. -->
{#if live && !isCompact && (live.crossborderTodayGwh || live.netCrossborderGwhYtd != null)}
<div class="mt-4 rounded-xs border border-stein-200 bg-white px-4 py-3 shadow-sm">
<FlowBalanceTodayYtd
today={live.crossborderTodayGwh}
ytd={live.netCrossborderGwhYtd != null && live.crossborderByCountryGwhYtd
? { total: live.netCrossborderGwhYtd, perCountry: live.crossborderByCountryGwhYtd }
: null}
/>
</div>
{#if live.crossborderTodayGwh || live.netCrossborderGwhYtd != null}
<div class="mt-2 rounded-xs border border-stein-200 bg-white px-4 py-2.5 shadow-sm">
<FlowBalanceTodayYtd
today={live.crossborderTodayGwh}
ytd={live.netCrossborderGwhYtd != null && live.crossborderByCountryGwhYtd
? { total: live.netCrossborderGwhYtd, perCountry: live.crossborderByCountryGwhYtd }
: null}
/>
</div>
{/if}
{#if block.installed_wind_onshore_gw || block.installed_solar_gw}
<div class="mt-2 rounded-xs border border-stein-200 bg-white px-4 py-2.5 shadow-sm">
<CapacityFactorBars
windOnshoreLiveMw={live.windOnshoreMw}
windOffshoreLiveMw={live.windOffshoreMw}
solarLiveMw={live.solarMw}
installedWindOnshoreGw={block.installed_wind_onshore_gw ?? 0}
installedWindOffshoreGw={block.installed_wind_offshore_gw ?? 0}
installedSolarGw={block.installed_solar_gw ?? 0}
installedAsOf={block.installed_as_of}
/>
</div>
{/if}
</details>
{/if}
<!-- Production breakdown: aufklappbar damit user die Summe nachvollziehen
kann. Werte sind aufeinander abgestimmt (gleicher SMARD-15-min-Slot). -->
{#if live}
kann. Werte sind aufeinander abgestimmt (gleicher SMARD-15-min-Slot).
Hero-Variante kapselt den minimalen Modus — Detail-Aufklapper würde
das brechen. -->
{#if live && !isHero}
<details class="mt-3 text-xs">
<summary
class="cursor-pointer text-stein-600 hover:text-stein-900"
>
{t(T.strommix_check_values)}
</summary>
<!-- grid: label/value-Paare als zwei klar getrennte Spalten
(auto / 1fr) statt grid-cols-2 — sonst alterniert das
Mapping pro Zelle und wirkt unruhig. Auf sm doppelt: 2
Paare nebeneinander. -->
<div
class="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 rounded-xs border border-stein-200 bg-stein-50 px-3 py-2 tabular-nums sm:grid-cols-3"
class="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 rounded-xs border border-stein-200 bg-stein-50 px-3 py-2 tabular-nums sm:grid-cols-[auto_1fr_auto_1fr] sm:gap-x-4"
>
<div class="text-stein-500">
{t(T.strommix_field_wind_onshore)}
@@ -664,4 +870,30 @@
.live-strommix [class*="border-stein"] {
transition: background-color 350ms ease, border-color 350ms ease, color 350ms ease;
}
/* Skeleton placeholder pulse — matches widget rhythm so the
loading state doesn't feel like a hang. */
.strommix-skel {
animation: strommix-skel-pulse 1400ms ease-in-out infinite;
}
@keyframes strommix-skel-pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.strommix-skel { animation: none; }
}
/* Print: open all <details> children so the printed page shows
the full picture, not just the summary chevron. The chevron
itself is hidden — it's only meaningful interactively. */
@media print {
.live-strommix details > *:not(summary) {
display: block !important;
}
.live-strommix details > summary {
list-style: none;
}
.live-strommix details > summary > :global(svg) {
display: none;
}
}
</style>
@@ -0,0 +1,91 @@
<script lang="ts">
import type { ResidualLoadPoint } from "$lib/strommix";
import { useTranslate, T } from "$lib/translations";
/** Daily per-slot data — the sparkline plots `weatherRenewableMw / loadMw`
* per slot. Compact, decorative, sits below the hero %. */
let {
points,
accent = "currentColor",
}: { points: ResidualLoadPoint[]; accent?: string } = $props();
const t = useTranslate();
const W = 240;
const H = 36;
const PAD = 2;
const series = $derived.by(() => {
return points
.filter((p) => p.loadMw > 0)
.map((p) => ({
unix: p.unix,
pct: (p.weatherRenewableMw / p.loadMw) * 100,
}));
});
const range = $derived.by(() => {
if (series.length === 0) return { minX: 0, maxX: 1, minY: 0, maxY: 100 };
const xs = series.map((s) => s.unix);
const ys = series.map((s) => s.pct);
return {
minX: Math.min(...xs),
maxX: Math.max(...xs),
minY: Math.max(0, Math.min(...ys) - 5),
maxY: Math.min(100, Math.max(...ys) + 5),
};
});
function xOf(unix: number): number {
const span = range.maxX - range.minX || 1;
return PAD + ((unix - range.minX) / span) * (W - 2 * PAD);
}
function yOf(pct: number): number {
const span = range.maxY - range.minY || 1;
return PAD + (1 - (pct - range.minY) / span) * (H - 2 * PAD);
}
const linePath = $derived.by(() => {
if (series.length < 2) return "";
return series
.map(
(s, i) => `${i === 0 ? "M" : "L"} ${xOf(s.unix).toFixed(1)} ${yOf(s.pct).toFixed(1)}`,
)
.join(" ");
});
const areaPath = $derived.by(() => {
if (series.length < 2) return "";
const top = linePath;
const last = series[series.length - 1];
const first = series[0];
return `${top} L ${xOf(last.unix).toFixed(1)} ${(H - PAD).toFixed(1)} L ${xOf(first.unix).toFixed(1)} ${(H - PAD).toFixed(1)} Z`;
});
const ariaLabel = $derived.by(() => {
if (series.length === 0) return "";
return t(T.strommix_sparkline_aria, {
from: Math.round(series[0].pct),
to: Math.round(series[series.length - 1].pct),
});
});
</script>
{#if series.length >= 2}
<svg
viewBox="0 0 {W} {H}"
preserveAspectRatio="none"
class="strommix-sparkline w-full max-w-[240px] h-9 mx-auto"
role="img"
aria-label={ariaLabel}
>
<path d={areaPath} fill={accent} fill-opacity="0.15" />
<path
d={linePath}
stroke={accent}
stroke-width="1.5"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{/if}
+10
View File
@@ -6,6 +6,16 @@
* consumes it.
*/
/** Default percentage thresholds (Wind+Solar / Last × 100) for bucket
* classification. Single source of truth both the CMS schema
* (`live_strommix.json5`) and the block (`StrommixBlock.svelte`) read
* these. Editor can override per-instance via `threshold_*` fields. */
export const STROMMIX_DEFAULT_THRESHOLDS = {
dunkelflaute: 15,
niedrig: 35,
mittel: 60,
} as const;
export type StrommixResponse = {
// Production-snapshot timestamp (Unix sec) — used as the "anchor" for
// the rest of the values. Cross-border + prices may have their own,
+33
View File
@@ -114,6 +114,28 @@ const TRANSLATION_KEYS = [
"calendar_time_suffix",
"calendar_select_day",
"calendar_next_events",
"calendar_all_upcoming",
"calendar_today",
"calendar_tomorrow",
"calendar_in_days", // {{n}}
"calendar_no_future_events",
"calendar_clear_filter",
"calendar_event_count", // {{n}}
"calendar_add_to_google",
"calendar_download_ics",
"calendar_open_maps",
"calendar_show_past",
"calendar_hide_past",
"calendar_past_events",
"calendar_filter_by_tag",
"calendar_all_tags",
"calendar_multi_day_range", // {{from}}, {{to}}
"calendar_starts_in", // {{n}}
"calendar_starts_in_minutes", // {{n}}
"calendar_running_now",
"calendar_jump_to_month",
"calendar_today_marker",
"calendar_untitled",
"calendar_show_more",
"calendar_show_less",
"post_overview_all",
@@ -207,6 +229,17 @@ const TRANSLATION_KEYS = [
// Compact variant for homepage / sidebar.
"strommix_compact_label", // {{pct}}, {{status}}
"strommix_compact_more",
// Akkordeon + history-panel headings.
"strommix_history_summary",
"strommix_residual_today_title",
"strommix_residual_today_desc",
// Loading / error UX.
"strommix_retry",
"strommix_trend_up", // {{delta}}
"strommix_trend_down", // {{delta}}
"strommix_trend_flat",
"strommix_sparkline_aria", // {{from}}, {{to}}
"strommix_stale_badge",
] as const;
export type TranslationKey = (typeof TRANSLATION_KEYS)[number];