feat(blocks): live_strommix widget for energy-charts data
Deploy / verify (push) Successful in 47s
Deploy / deploy (push) Successful in 53s

Editor-controlled live-strommix card that surfaces what share of
electricity demand is currently covered by weather-dependent
renewables (Wind+Solar / Load), plus the conventional generation that
fills the gap, the cross-border saldo, and the day-ahead price (DE/FR
side-by-side). Status bucket + label + argument come from the CMS so
editors can iterate copy without a deploy.

- LiveStrommixBlockData type next to the other block-types.
- New `/api/strommix` aggregator route bundles four CMS external
  collections (strommix_de, crossborder_de, price_de, price_fr) into a
  single payload, computes the renewable + conventional sums and the
  cross-border saldo (Σ flow_xx_gw, sign convention: + = export from
  DE), surfaces stale-on-error flags from the CMS response headers.
  Cache-control 5min so a single node only hits energy-charts once
  per window.
- StrommixBlock.svelte renders the card: percentage, bucket label,
  bucket argument (markdown), context row (conventional + saldo with
  ↑/↓ arrow), price row with optional FR comparison + negative-price
  badge, footer with disclaimer + "Wie wird das berechnet?" link +
  stale-data hint when the CMS served fallback. Re-polls every 5min
  client-side; bucket thresholds + labels + arguments come from the
  block instance so multiple placements can carry different copy.
- ContentRows.svelte dispatcher branch on `_type === "live_strommix"`.

Wired against `cms_content/_types/core/live_strommix.json5` (added in
the rustycms repo today). content_layout.row{1,2,3}Content already
allow the new block-type via the same commit on the CMS side.
This commit is contained in:
Peter Meier
2026-05-04 22:57:40 +02:00
parent a2ca0db9ca
commit 8ddfd026bc
5 changed files with 461 additions and 0 deletions
+26
View File
@@ -331,6 +331,32 @@ export interface OrganisationsBlockData {
layout?: BlockLayout;
}
/** Live-Strommix-Widget (_type: "live_strommix"). Editorial-Konfig + Platzierungs-Instanz.
* Fetched live data from /api/strommix (aggregator) and renders status bucket
* derived from `(wind_onshore + wind_offshore + solar) / load * 100`. */
export interface LiveStrommixBlockData {
_type?: "live_strommix";
_slug?: string;
intro_headline?: string;
intro_text?: string;
threshold_dunkelflaute?: number;
threshold_niedrig?: number;
threshold_mittel?: number;
label_dunkelflaute?: string;
label_niedrig?: string;
label_mittel?: string;
label_hoch?: string;
argument_dunkelflaute?: string;
argument_niedrig?: string;
argument_mittel?: string;
argument_hoch?: string;
enable_france_comparison?: boolean;
disclaimer?: string;
/** Reference (resolved: { _slug, _type, ...page-fields } or just slug string). */
explanation_page?: string | { _slug?: string; title?: string };
layout?: BlockLayout;
}
/** Kalender-Item (calendar_item) aus OpenAPI; terminZeit = ISO date-time. */
export type CalendarItemData = components["schemas"]["calendar_item"];
+4
View File
@@ -17,6 +17,7 @@
import OrganisationsBlock from "./blocks/OrganisationsBlock.svelte";
import OpnFormBlock from "./blocks/OpnFormBlock.svelte";
import DeadlineBannerBlock from "./blocks/DeadlineBannerBlock.svelte";
import StrommixBlock from "./blocks/StrommixBlock.svelte";
import { isBlockLayoutBreakout, getSpaceBottomClass } from "$lib/block-layout";
import type { BlockLayout } from "$lib/block-layout";
import type { Translations } from "$lib/translations";
@@ -41,6 +42,7 @@
OrganisationsBlockData,
OpnFormBlockData,
DeadlineBannerBlockData,
LiveStrommixBlockData,
} from "$lib/block-types";
let { layout, class: className = "", translations = {} }: { layout: RowContentLayout; class?: string; translations?: Translations | null } = $props();
@@ -140,6 +142,8 @@
<OpnFormBlock block={item as OpnFormBlockData} />
{:else if blockType(item) === "deadline_banner"}
<DeadlineBannerBlock block={item as DeadlineBannerBlockData} />
{:else if blockType(item) === "live_strommix"}
<StrommixBlock block={item as LiveStrommixBlockData} />
{:else}
<div class="col-span-12 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
Unbekannter Block: <code>{blockType(item)}</code>
@@ -0,0 +1,251 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { marked } from "$lib/markdown-safe";
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { LiveStrommixBlockData } from "$lib/block-types";
import type { StrommixResponse } from "$lib/strommix";
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
let { block }: { block: LiveStrommixBlockData } = $props();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
// Live data + lifecycle
let live = $state<StrommixResponse | null>(null);
let loadError = $state<string | null>(null);
let lastFetchAt = $state<number | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null;
async function loadLive() {
try {
const res = await fetch("/api/strommix", { cache: "no-store" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
live = (await res.json()) as StrommixResponse;
lastFetchAt = Date.now();
loadError = null;
} catch (e) {
loadError = e instanceof Error ? e.message : String(e);
}
}
onMount(() => {
loadLive();
pollTimer = setInterval(loadLive, 5 * 60 * 1000); // re-poll every 5 min
});
onDestroy(() => {
if (pollTimer) clearInterval(pollTimer);
});
// Derived numbers
const renewablePct = $derived.by(() => {
if (!live || live.loadMw <= 0) return null;
return (live.weatherRenewableMw / live.loadMw) * 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;
if (renewablePct < t1) return "dunkelflaute";
if (renewablePct < t2) return "niedrig";
if (renewablePct < t3) return "mittel";
return "hoch";
});
const statusLabel = $derived.by(() => {
if (!bucket) return "";
const labels: Record<Bucket, string | undefined> = {
dunkelflaute: block.label_dunkelflaute,
niedrig: block.label_niedrig,
mittel: block.label_mittel,
hoch: block.label_hoch,
};
return labels[bucket] ?? bucket;
});
const argumentMd = $derived.by(() => {
if (!bucket) return "";
const map: Record<Bucket, string | undefined> = {
dunkelflaute: block.argument_dunkelflaute,
niedrig: block.argument_niedrig,
mittel: block.argument_mittel,
hoch: block.argument_hoch,
};
return map[bucket] ?? "";
});
const argumentHtml = $derived(argumentMd ? (marked.parse(argumentMd) as string) : "");
const introHtml = $derived(block.intro_text ? (marked.parse(block.intro_text) as string) : "");
const disclaimerHtml = $derived(block.disclaimer ? (marked.parse(block.disclaimer) as string) : "");
const enableFr = $derived(block.enable_france_comparison !== false);
function fmtMw(mw: number): string {
return (mw / 1000).toLocaleString("de-DE", { maximumFractionDigits: 1 }) + " GW";
}
function fmtGw(gw: number): string {
return Math.abs(gw).toLocaleString("de-DE", { maximumFractionDigits: 1 }) + " GW";
}
function fmtPrice(eurMwh: number | null): string {
if (eurMwh == null) return "—";
return Math.round(eurMwh).toLocaleString("de-DE") + " €/MWh";
}
function fmtTime(unix: number | null): string {
if (!unix) return "";
const d = new Date(unix * 1000);
return d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
}
const bucketAccent = $derived.by(() => {
if (!bucket) return "neutral";
return bucket === "dunkelflaute"
? "danger"
: bucket === "niedrig"
? "warning"
: bucket === "mittel"
? "info"
: "success";
});
const accentClasses = $derived.by(() => {
switch (bucketAccent) {
case "danger":
return { box: "border-red-300 bg-red-50", pct: "text-red-800", label: "text-red-700" };
case "warning":
return { box: "border-amber-300 bg-amber-50", pct: "text-amber-800", label: "text-amber-700" };
case "info":
return { box: "border-sky-300 bg-sky-50", pct: "text-sky-800", label: "text-sky-700" };
case "success":
return { box: "border-emerald-300 bg-emerald-50", pct: "text-emerald-800", label: "text-emerald-700" };
default:
return { box: "border-neutral-200 bg-neutral-50", pct: "text-neutral-800", label: "text-neutral-700" };
}
});
const explanationHref = $derived.by(() => {
const ep = block.explanation_page;
if (!ep) return "";
if (typeof ep === "string") return ep ? `/${ep}/` : "";
if (ep._slug) return `/${ep._slug}/`;
return "";
});
const isStale = $derived(
!!(live && (live.staleStrommix || live.staleCrossborder || live.stalePrice))
);
const importDirection = $derived.by(() => {
if (!live) return null;
return live.netFlowGw >= 0 ? "Export" : "Import";
});
</script>
<div
class="live-strommix col-span-12 lg:col-span-8 lg:col-start-3 {layoutClasses}"
data-block-type="live_strommix"
data-block-slug={block._slug}
>
{#if block.intro_headline || introHtml}
<header class="mb-4">
{#if block.intro_headline}
<h2 class="text-2xl font-bold mb-2">{block.intro_headline}</h2>
{/if}
{#if introHtml}
<div class="prose prose-sm">{@html introHtml}</div>
{/if}
</header>
{/if}
<div class="rounded-lg border-2 shadow-sm overflow-hidden {accentClasses.box}">
<!-- Header bar -->
<div class="flex items-center justify-between px-4 py-2 border-b border-current/10 text-xs uppercase tracking-wide font-semibold {accentClasses.label}">
<span class="inline-flex items-center gap-1.5">
<Icon icon="mdi:transmission-tower" class="size-4" aria-hidden="true" />
Strommix jetzt
</span>
{#if live?.measuredAtUnix}
<span class="font-normal normal-case">Stand: {fmtTime(live.measuredAtUnix)} Uhr</span>
{/if}
</div>
<!-- Main: percentage + status -->
{#if loadError}
<div class="p-6 text-sm text-red-700">
Live-Daten nicht erreichbar: {loadError}
</div>
{:else if !live}
<div class="p-6 text-center text-neutral-500 text-sm">Lade Live-Daten…</div>
{:else if renewablePct == null}
<div class="p-6 text-sm text-amber-700">Keine gültigen Live-Werte verfügbar.</div>
{:else}
<div class="p-6 text-center">
<div class="text-6xl sm:text-7xl font-extrabold tabular-nums {accentClasses.pct}">
{Math.round(renewablePct)}%
</div>
<div class="mt-1 text-sm text-neutral-700">Wind &amp; Solar am Bedarf</div>
<div class="mt-3 text-base font-semibold {accentClasses.label}">{statusLabel}</div>
{#if argumentHtml}
<div class="prose prose-sm mt-3 max-w-prose mx-auto text-neutral-800">
{@html argumentHtml}
</div>
{/if}
</div>
<!-- Context row -->
<div class="grid grid-cols-2 divide-x divide-current/10 border-t border-current/10 text-sm">
<div class="px-4 py-3">
<div class="text-xs text-neutral-600 uppercase tracking-wide">Konventionell jetzt</div>
<div class="text-lg font-semibold tabular-nums">{fmtMw(live.conventionalMw)}</div>
</div>
<div class="px-4 py-3">
<div class="text-xs text-neutral-600 uppercase tracking-wide">
{importDirection ?? "Saldo"}
</div>
<div class="text-lg font-semibold tabular-nums">
{#if importDirection === "Import"}{/if}
{#if importDirection === "Export"}{/if}
{fmtGw(live.netFlowGw)}
</div>
</div>
</div>
<!-- Price row -->
<div class="border-t border-current/10 px-4 py-3 text-sm">
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
<span class="font-semibold">Strompreis DE: <span class="tabular-nums">{fmtPrice(live.priceDeEurMwh)}</span></span>
{#if enableFr && live.priceFrEurMwh != null}
<span class="text-neutral-600">(Frankreich: <span class="tabular-nums">{fmtPrice(live.priceFrEurMwh)}</span>)</span>
{/if}
{#if (live.priceDeEurMwh ?? 0) < 0}
<span class="ml-auto inline-flex items-center gap-1 text-amber-800 font-semibold">
<Icon icon="mdi:alert-circle-outline" class="size-4" aria-hidden="true" />
Negativpreis
</span>
{/if}
</div>
</div>
{/if}
</div>
<!-- Footer: disclaimer + explanation link + stale flag -->
{#if disclaimerHtml || explanationHref || isStale}
<footer class="mt-3 text-xs text-neutral-600 flex flex-wrap items-start gap-x-4 gap-y-1">
{#if disclaimerHtml}
<div class="flex-1 min-w-0 prose prose-xs">{@html disclaimerHtml}</div>
{/if}
{#if explanationHref}
<a href={explanationHref} class="underline hover:no-underline">
Wie wird das berechnet?
</a>
{/if}
{#if isStale}
<span class="inline-flex items-center gap-1 text-amber-700">
<Icon icon="mdi:cloud-off-outline" class="size-3.5" aria-hidden="true" />
gespeicherte Daten (Upstream gerade unerreichbar)
</span>
{/if}
</footer>
{/if}
</div>
+35
View File
@@ -0,0 +1,35 @@
/**
* Shared types for the Strommix-Live-Widget.
*
* The aggregator route at `/api/strommix` builds this payload from the
* four CMS external collections; the `StrommixBlock.svelte` component
* consumes it.
*/
export type StrommixResponse = {
measuredAtUnix: number | null;
// Production (MW)
windOnshoreMw: number;
windOffshoreMw: number;
solarMw: number;
biomassMw: number;
hydroMw: number;
ligniteMw: number;
hardCoalMw: number;
gasMw: number;
nuclearMw: number;
// Aggregates (MW)
loadMw: number;
weatherRenewableMw: number;
conventionalMw: number;
// Cross-border (GW; positive = net export from DE, negative = import)
netFlowGw: number;
flowsByCountry: Record<string, number>;
// Prices (EUR/MWh; null if upstream unavailable)
priceDeEurMwh: number | null;
priceFrEurMwh: number | null;
// Stale flags surfaced from the CMS' `x-rustycms-external-stale` header
staleStrommix: boolean;
staleCrossborder: boolean;
stalePrice: boolean;
};
+145
View File
@@ -0,0 +1,145 @@
/**
* Aggregator route for the Strommix-Live-Widget.
*
* Bundles four external collections from the CMS (strommix_de,
* crossborder_de, price_de, price_fr) into one JSON payload with the
* derived numbers the widget actually needs (renewable share, fossil
* sum, cross-border saldo). Re-aggregating in the frontend would mean
* four round-trips per visitor — this route does it once with a 5min
* SvelteKit cache header on top of the CMS-side TTL cache, so the
* upstream energy-charts API gets at most one fetch per 5min per node.
*
* Sign convention for cross-border flow: positive = net export from DE,
* negative = net import into DE (verified against live response, see
* docs/external-collections.md notes).
*/
import type { RequestHandler } from "./$types";
import { env } from "$env/dynamic/public";
import type { StrommixResponse } from "$lib/strommix";
const CMS_BASE = (
env.PUBLIC_CMS_URL ||
import.meta.env.PUBLIC_CMS_URL ||
"http://localhost:3000"
).replace(/\/$/, "");
type StrommixRaw = {
wind_onshore_mw?: number | null;
wind_offshore_mw?: number | null;
solar_mw?: number | null;
biomass_mw?: number | null;
hydro_mw?: number | null;
lignite_mw?: number | null;
hard_coal_mw?: number | null;
gas_mw?: number | null;
nuclear_mw?: number | null;
load_mw?: number | null;
measured_at_unix?: number | null;
};
type CrossborderRaw = {
flow_at_gw?: number | null;
flow_be_gw?: number | null;
flow_ch_gw?: number | null;
flow_cz_gw?: number | null;
flow_dk_gw?: number | null;
flow_fr_gw?: number | null;
flow_lu_gw?: number | null;
flow_nl_gw?: number | null;
flow_no_gw?: number | null;
flow_pl_gw?: number | null;
flow_se_gw?: number | null;
measured_at_unix?: number | null;
};
type PriceRaw = {
price_eur_mwh?: number | null;
measured_at_unix?: number | null;
};
function num(x: unknown): number {
return typeof x === "number" && Number.isFinite(x) ? x : 0;
}
async function fetchOne<T>(
collection: string,
fetcher: typeof fetch
): Promise<{ data: T | null; stale: boolean }> {
try {
const res = await fetcher(
`${CMS_BASE}/api/content/${collection}/current`,
{ signal: AbortSignal.timeout(8_000) }
);
if (!res.ok) return { data: null, stale: false };
const stale = res.headers.get("x-rustycms-external-stale") === "1";
const data = (await res.json()) as T;
return { data, stale };
} catch {
return { data: null, stale: false };
}
}
export const GET: RequestHandler = async ({ fetch }) => {
const [strommix, cross, priceDe, priceFr] = await Promise.all([
fetchOne<StrommixRaw>("strommix_de", fetch),
fetchOne<CrossborderRaw>("crossborder_de", fetch),
fetchOne<PriceRaw>("price_de", fetch),
fetchOne<PriceRaw>("price_fr", fetch),
]);
const s = strommix.data ?? {};
const c = cross.data ?? {};
const weatherRenewableMw = num(s.wind_onshore_mw) + num(s.wind_offshore_mw) + num(s.solar_mw);
const conventionalMw =
num(s.lignite_mw) + num(s.hard_coal_mw) + num(s.gas_mw) + num(s.nuclear_mw);
const flowsByCountry: Record<string, number> = {
AT: num(c.flow_at_gw),
BE: num(c.flow_be_gw),
CH: num(c.flow_ch_gw),
CZ: num(c.flow_cz_gw),
DK: num(c.flow_dk_gw),
FR: num(c.flow_fr_gw),
LU: num(c.flow_lu_gw),
NL: num(c.flow_nl_gw),
NO: num(c.flow_no_gw),
PL: num(c.flow_pl_gw),
SE: num(c.flow_se_gw),
};
const netFlowGw = Object.values(flowsByCountry).reduce((a, b) => a + b, 0);
const body: StrommixResponse = {
measuredAtUnix: s.measured_at_unix ?? null,
windOnshoreMw: num(s.wind_onshore_mw),
windOffshoreMw: num(s.wind_offshore_mw),
solarMw: num(s.solar_mw),
biomassMw: num(s.biomass_mw),
hydroMw: num(s.hydro_mw),
ligniteMw: num(s.lignite_mw),
hardCoalMw: num(s.hard_coal_mw),
gasMw: num(s.gas_mw),
nuclearMw: num(s.nuclear_mw),
loadMw: num(s.load_mw),
weatherRenewableMw,
conventionalMw,
netFlowGw,
flowsByCountry,
priceDeEurMwh: priceDe.data?.price_eur_mwh ?? null,
priceFrEurMwh: priceFr.data?.price_eur_mwh ?? null,
staleStrommix: strommix.stale,
staleCrossborder: cross.stale,
stalePrice: priceDe.stale || priceFr.stale,
};
return new Response(JSON.stringify(body), {
headers: {
"content-type": "application/json",
// 5min: matches the CMS-side TTL on strommix + crossborder.
// Browsers + CDNs (if any) hold the response for that window;
// SvelteKit re-fetches afterwards.
"cache-control": "public, max-age=300, s-maxage=300",
},
});
};