feat(blocks): live_strommix widget for energy-charts data
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:
@@ -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",
|
||||
},
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user