diff --git a/src/lib/block-types.ts b/src/lib/block-types.ts
index 39962d2..b9200f1 100644
--- a/src/lib/block-types.ts
+++ b/src/lib/block-types.ts
@@ -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"];
diff --git a/src/lib/components/ContentRows.svelte b/src/lib/components/ContentRows.svelte
index ca7418f..2af6905 100644
--- a/src/lib/components/ContentRows.svelte
+++ b/src/lib/components/ContentRows.svelte
@@ -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 @@
{:else if blockType(item) === "deadline_banner"}
+ {:else if blockType(item) === "live_strommix"}
+
{:else}
Unbekannter Block:
{blockType(item)}
diff --git a/src/lib/components/blocks/StrommixBlock.svelte b/src/lib/components/blocks/StrommixBlock.svelte
new file mode 100644
index 0000000..c63ddfd
--- /dev/null
+++ b/src/lib/components/blocks/StrommixBlock.svelte
@@ -0,0 +1,251 @@
+
+
+
+ {#if block.intro_headline || introHtml}
+
+ {#if block.intro_headline}
+ {block.intro_headline}
+ {/if}
+ {#if introHtml}
+ {@html introHtml}
+ {/if}
+
+ {/if}
+
+
+
+
+
+
+ Strommix jetzt
+
+ {#if live?.measuredAtUnix}
+ Stand: {fmtTime(live.measuredAtUnix)} Uhr
+ {/if}
+
+
+
+ {#if loadError}
+
+ Live-Daten nicht erreichbar: {loadError}
+
+ {:else if !live}
+
Lade Live-Daten…
+ {:else if renewablePct == null}
+
Keine gültigen Live-Werte verfügbar.
+ {:else}
+
+
+
Wind & Solar am Bedarf
+
→ {statusLabel}
+ {#if argumentHtml}
+
+ {@html argumentHtml}
+
+ {/if}
+
+
+
+
+
+
Konventionell jetzt
+
{fmtMw(live.conventionalMw)}
+
+
+
+ {importDirection ?? "Saldo"}
+
+
+ {#if importDirection === "Import"}↓{/if}
+ {#if importDirection === "Export"}↑{/if}
+ {fmtGw(live.netFlowGw)}
+
+
+
+
+
+
+
+ Strompreis DE: {fmtPrice(live.priceDeEurMwh)}
+ {#if enableFr && live.priceFrEurMwh != null}
+ (Frankreich: {fmtPrice(live.priceFrEurMwh)})
+ {/if}
+ {#if (live.priceDeEurMwh ?? 0) < 0}
+
+
+ Negativpreis
+
+ {/if}
+
+
+ {/if}
+
+
+
+ {#if disclaimerHtml || explanationHref || isStale}
+
+ {/if}
+
diff --git a/src/lib/strommix.ts b/src/lib/strommix.ts
new file mode 100644
index 0000000..b5a69ef
--- /dev/null
+++ b/src/lib/strommix.ts
@@ -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
;
+ // 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;
+};
diff --git a/src/routes/api/strommix/+server.ts b/src/routes/api/strommix/+server.ts
new file mode 100644
index 0000000..4c92385
--- /dev/null
+++ b/src/routes/api/strommix/+server.ts
@@ -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(
+ 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("strommix_de", fetch),
+ fetchOne("crossborder_de", fetch),
+ fetchOne("price_de", fetch),
+ fetchOne("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 = {
+ 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",
+ },
+ });
+};