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} + +
+ +
+ + + {#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} +
+
+ {Math.round(renewablePct)}% +
+
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} + + + {/if} +
+
+ {/if} +
+ + + {#if disclaimerHtml || explanationHref || isStale} +
+ {#if disclaimerHtml} +
{@html disclaimerHtml}
+ {/if} + {#if explanationHref} + + Wie wird das berechnet? + + {/if} + {#if isStale} + + + {/if} +
+ {/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", + }, + }); +};